diff --git a/.swift-format b/.swift-format
new file mode 100644
index 00000000..288e37de
--- /dev/null
+++ b/.swift-format
@@ -0,0 +1,52 @@
+{
+ "version": 1,
+ "indentation": {
+ "spaces": 4
+ },
+ "lineLength": 120,
+ "rules": {
+ "AllPublicDeclarationsHaveDocumentation": false,
+ "AlwaysUseLiteralForEmptyCollectionInit": false,
+ "AlwaysUseLowerCamelCase": false,
+ "AmbiguousTrailingClosureOverload": false,
+ "AvoidRetroactiveConformances": false,
+ "BeginDocumentationCommentWithOneLineSummary": false,
+ "DoNotUseSemicolons": false,
+ "DontRepeatTypeInStaticProperties": false,
+ "FileScopedDeclarationPrivacy": false,
+ "FullyIndirectEnum": false,
+ "GroupNumericLiterals": false,
+ "IdentifiersMustBeASCII": false,
+ "NeverForceUnwrap": false,
+ "NeverUseForceTry": false,
+ "NeverUseImplicitlyUnwrappedOptionals": false,
+ "NoAccessLevelOnExtensionDeclaration": false,
+ "NoAssignmentInExpressions": false,
+ "NoBlockComments": false,
+ "NoCasesWithOnlyFallthrough": false,
+ "NoEmptyLinesOpeningClosingBraces": false,
+ "NoEmptyTrailingClosureParentheses": false,
+ "NoLabelsInCasePatterns": false,
+ "NoLeadingUnderscores": false,
+ "NoParensAroundConditions": false,
+ "NoPlaygroundLiterals": false,
+ "NoVoidReturnOnFunctionSignature": false,
+ "OmitExplicitReturns": false,
+ "OneCasePerLine": false,
+ "OneVariableDeclarationPerLine": false,
+ "OnlyOneTrailingClosureArgument": false,
+ "OrderedImports": false,
+ "ReplaceForEachWithForLoop": false,
+ "ReturnVoidInsteadOfEmptyTuple": false,
+ "TypeNamesShouldBeCapitalized": false,
+ "UseEarlyExits": false,
+ "UseExplicitNilCheckInConditions": false,
+ "UseLetInEveryBoundCaseVariable": false,
+ "UseShorthandTypeNames": false,
+ "UseSingleLinePropertyGetter": false,
+ "UseSynthesizedInitializer": false,
+ "UseTripleSlashForDocumentationComments": false,
+ "UseWhereClausesInForLoops": false,
+ "ValidateDocumentationComments": false
+ }
+}
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/CodexReview.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/CodexReview.xcscheme
index 9a7e88e2..c541fca7 100644
--- a/.swiftpm/xcode/xcshareddata/xcschemes/CodexReview.xcscheme
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/CodexReview.xcscheme
@@ -15,9 +15,9 @@
buildForAnalyzing = "YES">
@@ -77,20 +77,6 @@
ReferencedContainer = "container:">
-
-
-
-
@@ -149,16 +135,6 @@
ReferencedContainer = "container:">
-
-
-
-
domain review event
- -> observable review timeline
- -> UI / MCP / rendering / legacy log projections
+codex app-server
+ -> CodexAppServerKit typed app-server APIs
+ -> CodexDataKit CodexChat snapshot/change streams
+ -> ReviewMonitor UI / MCP log projections
```
+Review execution lifecycle flows separately:
+
+```text
+codex app-server review session
+ -> CodexReviewAppServer lifecycle adapter
+ -> CodexReviewKit ReviewRunRecord
+ -> ReviewMonitor UI / MCP lifecycle projections
+```
+
+`ReviewRunRecord` is not a transcript, log, final-review, or timeline source.
+It tracks lifecycle, cancellation, restart, recovery, ownership, and MCP command
+state for a review run. `CodexChat` is the source of truth for review content
+after a run has an associated chat/turn.
+
Raw JSON-RPC notifications are an input boundary only. They must not become the
-source of truth for ReviewMonitor UI, MCP responses, or legacy logs after they
-have been converted into domain events.
+source of truth for ReviewMonitor UI or MCP responses after they have been
+normalized into typed app-server APIs and CodexDataKit models.
## Targets
+This table describes intended ownership.
+
| Target | Responsibility |
| --- | --- |
-| `CodexReviewDomain` | Semantic review identifiers, kinds, runs, jobs, `ReviewDomainEvent`, `ReviewTimeline`, and `ReviewTimelineItem` |
-| `CodexReviewApplication` | Observation-oriented application store/use-case primitives, including `ReviewStore` and `ReviewObservationAwaiter` |
-| `CodexReview` | Public ReviewMonitor API, `CodexReviewStore`, auth/settings/runtime product state, and legacy log compatibility projections |
-| `CodexReviewAppServerWire` | Raw `codex app-server` review notification DTO decode and conversion into domain events |
-| `CodexReviewAppServer` | Live `codex app-server` process, JSON-RPC transport, request serialization, notification routing, and runtime conversion |
-| `CodexReviewMCPAdapter` | MCP-facing projections from observable review/domain state |
-| `CodexReviewMCPServer` | Internal MCP protocol request/response conversion and Streamable HTTP endpoint |
+| `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 |
+| `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 |
-| `ReviewMonitorRendering` | Domain timeline rendering helpers that do not know AppKit/SwiftUI or app-server wire |
-| `ReviewUI` | Native monitor UI rendering and user-intent forwarding |
+| `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 |
| `TextTransitions` | UI text transition view support |
@@ -41,130 +53,142 @@ runtime together; lower targets do not import the host.
## Source Of Truth
-`CodexReviewDomain` owns the semantic timeline vocabulary. It defines the
-review item kinds, timeline item content, and `ReviewDomainEvent` values that
-describe review progress independently of any transport, UI, or MCP protocol.
-
-`CodexReviewAppServerWire` owns raw app-server notification shapes. Its job is
-to decode wire payloads and expose domain events. It may depend on
-`CodexReviewDomain`, but it must not import `CodexReviewApplication`, UI,
-rendering, or MCP targets.
-
-`CodexReviewAppServer` owns the live process and JSON-RPC connection. It is the
-runtime boundary that consumes app-server requests/notifications and hands
-application-facing review events to the store. App-server wire details stop at
-this boundary.
-
-`ReviewTimeline` and application/store state are the observable source of truth
-after conversion. UI views, rendering helpers, MCP adapters, and legacy log
-support project from the timeline; they do not parse raw app-server events and
-do not make string logs authoritative again.
-
-Legacy log support exists for compatibility with older ReviewMonitor surfaces:
-
-- `ReviewLogEntryTimelineProjection` rebuilds semantic timeline state from
- existing log entries during migration or compatibility paths.
-- `ReviewTimelineLegacyLogProjection` derives legacy log entries from timeline
- items when old APIs need them.
-
-Those projections are compatibility edges. New behavior should prefer domain
-events and timeline state as the owner.
+`CodexDataKit` owns generic Codex model state. ReviewMonitor should use
+`CodexFetchedResults` for chat lists and `CodexChat` snapshot/change
+streams for selected chat content. Collection/list UI observes these owners
+through ObservationBridge. Large transcript/log surfaces consume APIs that emit
+an initial snapshot followed by row-level changes so the UI can append or update
+content without re-reading the whole transcript.
+
+`CodexReviewKit` owns review-run lifecycle state. A run records:
+
+- target and command metadata,
+- current status and lifecycle message,
+- associated source/review thread and turn identifiers,
+- cancellation and cleanup metadata,
+- restart and recovery state,
+- MCP session ownership and command visibility.
+
+The run lifecycle may say that a review succeeded, failed, or was cancelled. It
+does not contain the final review text. MCP `review.finalReview` and the detail
+log are derived from the CodexChat projection when a projection is available.
+When no CodexChat projection is available, MCP may report terminal lifecycle
+state with no final review.
+
+`ReviewMonitorLog` and MCP log values are render/protocol projections. They can
+be rebuilt from `CodexChat` turn snapshots plus changes. They must not become
+authoritative model state.
+
+`ReviewUI` owns transient presentation state only: selected chat, expanded
+sidebar groups, local filter text/mode, drag state, context-menu dispatch, view
+controller lifetime, and installed native views. User actions call store or
+CodexDataKit model APIs; UI code does not parse app-server wire data.
## Target Graph
```mermaid
flowchart TB
- subgraph Domain["CodexReviewDomain"]
- DomainEvents["ReviewDomainEvent"]
- Timeline["ReviewTimeline"]
- end
-
- subgraph Application["CodexReviewApplication"]
- AppStore["ReviewStore"]
- Awaiter["ReviewObservationAwaiter"]
+ subgraph AppServerKit["CodexAppServerKit"]
+ Server["CodexAppServer"]
+ ReviewSession["CodexReviewSession"]
+ Client["JSON-RPC client"]
+ Process["codex app-server"]
end
- subgraph Product["CodexReview"]
- PublicStore["CodexReviewStore"]
- LegacyProjection["Legacy log projections"]
+ subgraph DataKit["CodexDataKit"]
+ Context["CodexModelContext"]
+ Fetch["CodexFetchedResults"]
+ Chat["CodexChat"]
end
- subgraph Wire["CodexReviewAppServerWire"]
- WireDTO["Raw app-server DTOs"]
+ subgraph ReviewCore["CodexReviewKit"]
+ Store["CodexReviewStore"]
+ Run["ReviewRunRecord"]
+ Awaiter["ReviewObservationAwaiter"]
end
- subgraph AppServer["CodexReviewAppServer"]
- Client["JSON-RPC client"]
- Runtime["Review runtime"]
- Process["codex app-server"]
+ subgraph AppServerAdapter["CodexReviewAppServer"]
+ Adapter["Review lifecycle adapter"]
end
- subgraph MCP["MCP"]
- MCPAdapter["CodexReviewMCPAdapter"]
- MCPServer["CodexReviewMCPServer"]
+ subgraph MCP["CodexReviewMCPServer"]
+ MCPServer["MCP tools"]
+ MCPProjection["CodexChat log projection"]
end
- subgraph Monitor["Monitor surfaces"]
- Renderer["ReviewMonitorRendering"]
- UI["ReviewUI"]
+ subgraph UI["ReviewUI"]
+ Sidebar["CodexChat sidebar"]
+ Detail["CodexChat detail log"]
+ Presentation["Selection, filters, DnD, context menus"]
end
subgraph Host["CodexReviewHost"]
Composition["Composition root"]
end
- WireDTO --> DomainEvents
- DomainEvents --> Timeline
- Timeline --> AppStore
- Timeline --> PublicStore
- Timeline --> LegacyProjection
- Timeline --> MCPAdapter
- Timeline --> Renderer
- PublicStore --> UI
- Renderer --> UI
- MCPAdapter --> MCPServer
- Runtime --> WireDTO
- Runtime --> PublicStore
+ Server --> Client
+ ReviewSession --> Client
Client --> Process
- Runtime --> Client
- Composition --> Runtime
+ 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 --> PublicStore
+ Composition --> Sidebar
```
-The diagram describes ownership direction, not every SwiftPM dependency.
-Compatibility targets may currently expose older store APIs, but new code
-should not create reverse imports from Domain, Wire, Application, Rendering, UI,
-or MCP adapter targets back into runtime/protocol owners.
+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.
## Observation Ownership
ObservationBridge is a subscription primitive. It is not storage, cache, or a
source of truth.
-- The observable owner keeps semantic state: domain timelines, application
- stores, and product stores.
+- The observable owner keeps semantic state: `CodexFetchedResults`,
+ `CodexChat`, and `CodexReviewStore`.
- Subscription tokens live with the subscriber that created them. A view
controller, awaiter, or driver that starts observation is responsible for
cancelling its token when that owner ends.
-- `ReviewObservationAwaiter` belongs in `CodexReviewApplication` because it is a
- use-case-level awaiter over observable domain state.
-- UI observation tokens are view/controller lifetime details. Any UI projection
- derived from observed state is transient and can be rebuilt from the timeline.
-- MCP and rendering projections are value snapshots over timeline state. They
- must not retain ObservationBridge tokens or persist their projection as model
- state.
-
-## CodexReview
-
-`CodexReview` is the public product surface used by existing ReviewMonitor code.
-It owns review commands, auth/settings/runtime state, network recovery policy,
-diagnostics, and legacy store APIs through `CodexReviewStore`.
+- `ReviewObservationAwaiter` belongs in `CodexReviewKit` because it is a
+ use-case-level awaiter over review lifecycle state.
+- Sidebar cells and native collection/outline rows should observe their own
+ row models through ObservationBridge. Selection changes must not force every
+ cell to re-render.
+- Log surfaces should consume chat snapshot/change streams directly. Append-only
+ text should be applied incrementally instead of rebuilding visible content
+ from an observed array whenever possible.
+- MCP projections are value snapshots over store state plus CodexChat
+ projections. They must not retain ObservationBridge tokens or persist their
+ projection as model state.
+
+## CodexReviewKit
+
+`CodexReviewKit` is the public review product surface used by ReviewMonitor and
+the MCP server. It owns review commands, auth/settings/runtime state, network
+recovery policy, diagnostics, and review-run lifecycle through
+`CodexReviewStore`.
`CodexReviewStore` remains the command owner for `review_start`,
`review_await`, `review_read`, `review_list`, `review_cancel`, session close,
-auth actions, and settings updates. It depends on domain timeline types and
-application awaiters instead of owning app-server wire shapes.
+auth actions, and settings updates. It coordinates lifecycle state and selected
+CodexChat projections; it does not store transcript text on the run.
`CodexReviewStoreBackend` is the dependency boundary below the store. Live,
preview, and test backends all implement that boundary; product state remains in
@@ -172,33 +196,44 @@ the store.
## App-Server Gateway
-`CodexReviewAppServer` treats raw JSON-RPC as the only live I/O boundary.
+`CodexAppServerKit` treats raw JSON-RPC as the only live I/O boundary.
- One live `codex app-server` process maps to one shared connection.
- `initialize` and `initialized` run once per connection.
-- `config/read`, `account/read`, `thread/start`, `review/start`,
- `turn/interrupt`, `thread/unsubscribe`, and
- `thread/backgroundTerminals/clean` are typed requests.
-- Same-thread mutating requests are serialized.
+- `config/read`, `account/read`, login, model, thread, and turn methods are
+ typed requests in the generic Kit boundary.
+- The public Kit API is expressed as `CodexAppServer`, Codex thread/turn values,
+ prompt/response APIs, Codex-specific response stream controls, thread event
+ streams, messages, transcript/log values, high-level review sessions, model
+ values, account values, and login handles.
+- App-server notification schemas belong inside the Kit boundary. Public review
+ surfaces should be high-level sessions and CodexDataKit models, not raw
+ notification DTOs.
+- Same-thread mutating requests are serialized. `turn/interrupt` is the
+ intentional control-path exception so an in-flight response can be stopped
+ without waiting behind queued same-thread work.
- Different-thread requests may run concurrently.
-- `turn/interrupt` is a control request and is not queued behind an in-flight
- same-thread `review/start`.
-- Notifications are subscribed before `review/start` so terminal events emitted
- with the response are not lost.
+- Notifications are routed by turn ID, early turn notifications are replayed to
+ later consumers, and schema-new notifications are preserved as unknown domain
+ values.
- Cancellation is represented by typed control/cleanup requests, not by closing
the transport.
+`CodexReviewAppServer` adapts high-level `CodexReviewSession` lifecycle events
+into ReviewMonitor backend events and adds ReviewMonitor-specific cleanup and
+recovery on top of the generic boundary. Agent messages and message deltas are
+not review lifecycle events; they belong to CodexChat.
+
Fake and live tests use the same transport protocol.
## MCP Boundary
`CodexReviewMCPServer` knows MCP tool names, request arguments, response shape,
-session headers, and Streamable HTTP behavior. It calls store commands and
-adapter projections. It does not know Codex JSON-RPC details.
-
-`CodexReviewMCPAdapter` may depend on domain/product state to build MCP-facing
-value snapshots. It must not import ReviewUI, the app-server runtime, or
-app-server wire DTOs.
+session headers, Streamable HTTP behavior, and MCP-facing value snapshots from
+store state plus CodexChat projections. It calls store commands and projects
+selected CodexChat turns into MCP log/review values. It does not know Codex
+JSON-RPC details, does not depend on the UI or app-server backend, and must not
+import ReviewUI, the app-server runtime, or app-server wire DTOs.
ReviewMonitor owns the default Streamable HTTP endpoint at
`http://localhost:9417/mcp`. The HTTP boundary follows current MCP session
@@ -216,20 +251,46 @@ The public tool surface is:
## Monitor UI Boundary
-`ReviewUI` observes product/domain state and forwards user intent.
+`ReviewUI` observes CodexDataKit and review product state, then forwards user
+intent.
- Views and view controllers render observable state.
-- User actions call store methods.
-- UI rendering may use `ReviewMonitorRendering` helpers over `ReviewTimeline`.
-- UI code must not import app-server runtime, app-server wire, MCP adapter, or
- MCP server targets.
+- User actions call store or CodexDataKit model methods.
+- Sidebar list input should come from `CodexFetchedResults` grouped
+ by `CodexWorkspaceGroup`/`CodexWorkspace`, with ReviewMonitor-specific review
+ status badges overlaid from review-run lifecycle state.
+- Detail log input should come from the selected `CodexChat` snapshot/change
+ stream.
+- Generic Codex model state should come from `CodexDataKit` owners instead of
+ ReviewMonitor-local models when a reusable owner exists.
+- UI code must not import app-server runtime, app-server wire, or MCP server
+ targets.
- UI tests cover layout, selection, rendering, accessibility-facing text, and
user-intent forwarding.
- Review/auth/settings semantics are tested in lower target tests.
-`ReviewMonitorRendering` is intentionally lower than UI. It can render domain
-timeline values, but it must not import AppKit/SwiftUI UI, app-server, wire, or
-MCP targets.
+The current ReviewMonitor UI model boundary is therefore:
+
+- `CodexDataKit`: generic observable app-server models and fetch/query owners.
+- `CodexReviewKit`: review-run lifecycle, auth/settings product state, product
+ commands, cancellation/restart/recovery, and MCP-facing run ownership.
+- `ReviewUI`: native AppKit/SwiftUI rendering state such as sidebar selection,
+ filters, drag state, installed controllers, row views, context menus, and
+ transient presentation.
+
+CodexDataKit migration candidates should be split by owner:
+
+- Account owner: active-account loading, login progress, login
+ cancellation/completion, logout, account events, and rate-limit windows over
+ `CodexAppServer`.
+- Model-configuration owner: model catalog loading, current configuration,
+ normalized review-model/reasoning/service-tier selection, and configuration
+ persistence over `CodexAppServer`.
+
+ReviewMonitor-specific account-switch confirmation, running-review warning
+policy, review cancellation/restart, persisted ReviewMonitor sidebar state, and
+MCP-facing review result state remain in CodexReviewKit/ReviewUI unless they
+become generic Codex product behavior.
## Testing
@@ -237,23 +298,22 @@ Default tests are deterministic and do not start a live `codex app-server`.
| Test area | Uses | Verifies |
| --- | --- | --- |
-| `CodexReviewDomainTests` | Domain timelines and events | Semantic timeline mutation and terminal state |
-| `CodexReviewApplicationTests` | Domain timelines and ObservationBridge awaiters | Use-case observation behavior |
-| `CodexReviewAppServerWireTests` | Raw notification JSON | Wire decode and domain event conversion |
-| `CodexReviewTests` | Fake `CodexReviewStoreBackend` | Review/auth/settings state machines, cancellation, result retention |
-| `CodexReviewAppServerTests` | Fake JSON-RPC transport | App-server schema, serialization, notification buffering, interrupt/cleanup |
-| `CodexReviewMCPAdapterTests` | Domain timeline projections | MCP adapter snapshots |
-| `CodexReviewMCPServerTests` | Fake review store | MCP protocol conversion and response shape |
+| `CodexAppServerKitTests` | Fake JSON-RPC transport | Generic app-server handshake, request serialization, retry, notification routing, domain result aggregation, and high-level review stream behavior |
+| `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 | Native UI behavior and user-intent forwarding |
-| `ArchitectureFenceTests` | Source scan | Target ownership and forbidden implementation imports |
+| `ReviewUITests` | Preview/test monitor backend and CodexChat fixtures | Native UI behavior, CodexChat rendering, context menus, DnD/filter presentation, and user-intent forwarding |
Forbidden test patterns:
- Sleeping to wait for lifecycle progress when an explicit signal can be used.
+- Enforcing architecture with string-scan or `ArchitectureFence`-style tests.
+ Target ownership should be covered by contract and behavior tests at the
+ relevant boundary.
- Inspecting fake-only storage as product behavior.
- Starting a live `codex app-server` in default CI tests.
- Testing behavior only because another implementation happened to behave
differently.
-- Parsing raw app-server wire or string logs from UI/MCP tests when a domain
- timeline or store projection can express the behavior.
+- Parsing raw app-server wire or string logs from UI/MCP tests when CodexChat or
+ review-run lifecycle state can express the behavior.
diff --git a/Docs/mcp.md b/Docs/mcp.md
index 07ee69dd..cc54a2ad 100644
--- a/Docs/mcp.md
+++ b/Docs/mcp.md
@@ -7,10 +7,10 @@ endpoint.
- App-managed Streamable HTTP MCP endpoint at `http://localhost:9417/mcp`
- Multi-session
-- Session-scoped review jobs
+- Session-scoped review runs
- One long-lived `codex app-server` backend process
- One shared internal transport to the backend process
-- Review jobs run concurrently across sessions and within the same session
+- Review runs run concurrently across sessions and within the same session
## Tools
@@ -32,7 +32,7 @@ Key inputs:
Returns:
-- `jobId`
+- `runId`
- `run`
- `reviewThreadId`
- `threadId`
@@ -47,23 +47,31 @@ Returns:
- `cancellable`
- `cancellation` when cancellation metadata is available
- `errorMessage`
-- `output`
- - `summary`
- - `review`
+- `review`
- `hasFinalReview`
- - `lastAgentMessage`
- - `reviewResult` parsed finding state (`hasFindings`, `noFindings`, or `unknown`) with title/body/location fields when available
+ - `finalReview` from the terminal Codex review response
+ - `reviewResult` parsed finding state (`hasFindings`, `noFindings`, or `unknown`) with title/body/location fields when a final review is available
+- `log`
+ - `orderedEntryIds`
+ - `activeEntryIds`
+ - `activeEntryCount`
+ - `latestEntryId`
+ - `finalLifecycleMessage`
+ - `finalResult`
+ - `itemsPage`
+ - `items` for detailed reads
Notes:
-- `review_start` is the primary client flow. Codex clients wait for terminal completion. Claude Code clients wait up to 540 seconds; if the job is still running, call `review_await` with the returned `jobId`.
+- `lifecycle.message` is review-run lifecycle text. It is not final review content.
+- `review.finalReview` comes from the terminal review response. Chat log projection is only used for `log` details.
+- `review_start` is the primary client flow. Codex clients wait for terminal completion. Claude Code clients wait up to 540 seconds; if the run is still running, call `review_await` with the returned `runId`.
- ReviewMonitor resolves the reported review model in this order:
1. `~/.codex_review/config.toml` `review_model`
2. the effective dedicated Codex config in `~/.codex_review/config.toml` `review_model`
3. backend-reported `thread/start.model`
4. the effective dedicated Codex config in `~/.codex_review/config.toml` `model` only as a pre-thread-start fallback when the backend does not report a model
-- Use `review_read` to fetch paged, ordered `logs`. `rawLogText` is the
- diagnostic/raw projection and is not a full log transcript.
+- Use `review_read` to fetch detailed Codex chat log projection for a run.
If you are unsure how to build the `target` object, read:
@@ -76,55 +84,38 @@ If you are unsure how to build the `target` object, read:
### `review_await`
-Waits for a running review job owned by the current MCP session. The wait is
+Waits for a running review run owned by the current MCP session. The wait is
bounded to 540 seconds so clients with fixed activity watchdogs can continue
waiting with another tool call.
Inputs:
-- `jobId` or `jobID`
+- `runId` or `runID`
-Returns the same lightweight shape as `review_start`: `jobId`, `run`,
-`lifecycle`, and `output`. It does not include `logs` or `rawLogText`; use
-`review_read` when log pages are needed.
+Returns the same lightweight shape as `review_start`: `runId`, `run`,
+`lifecycle`, `review`, and a compact `log`. Use `review_read` when log item
+details are needed.
-If the job is still running after the bounded wait, call `review_await` again
-with the same `jobId`.
+If the run is still running after the bounded wait, call `review_await` again
+with the same `runId`.
### `review_read`
-Reads the current or final state of a review job owned by the current MCP session.
-This is optional for normal clients because `review_start` already returns the final summary.
-
-Optional inputs:
-
-- `logOffset` 0-based log page offset. If omitted, `review_read` returns the
- latest page.
-- `logLimit` page size, default `100`, max `500`
-- `logFilter` `default` excludes command output; `all` includes it
+Reads the current or final state of a review run owned by the current MCP session.
+This is optional for normal clients because `review_start` already returns the terminal lifecycle state and final review when a Codex chat projection is available.
Returns:
-- `jobId`
+- `runId`
- `run`
- `lifecycle`
-- `output`
-- `logs` paged read projection. Grouped replacement/delta entries are folded
- into their current value before paging.
-- `logsPage`
- - `total`
- - `offset`
- - `limit`
- - `returned`
- - `hasMoreBefore`
- - `hasMoreAfter`
- - `previousOffset`
- - `nextOffset`
-- `rawLogText` diagnostic/raw projection, not a full transcript
+- `review`
+- `log` with ordered item IDs, active item IDs, terminal lifecycle/final review
+ values, paging metadata, and item details.
### `review_list`
-Lists review jobs owned by the current MCP session.
+Lists review runs owned by the current MCP session.
Optional inputs:
@@ -135,21 +126,21 @@ Optional inputs:
Returns:
- `items`
- - `jobId`
+ - `runId`
- `cwd`
- `targetSummary`
- `run`
- `lifecycle`
- - `output`
+ - `review`
### `review_cancel`
-Cancels a review job owned by the current MCP session.
+Cancels a review run owned by the current MCP session.
Inputs:
- exact:
- - `jobId`
+ - `runId`
- selector:
- `cwd`
- `statuses`
@@ -157,7 +148,7 @@ Inputs:
Notes:
- `cwd` is a search key, not a unique identifier.
-- Without `jobId`, `review_cancel` searches only the current MCP session.
+- Without `runId`, `review_cancel` searches only the current MCP session.
- Responses include `lifecycle.cancellation.source` and `lifecycle.cancellation.message` when cancellation metadata is available. UI-triggered cancellations use `source: "userInterface"`.
## Discovery Resources
diff --git a/Package.resolved b/Package.resolved
index 008e61b9..02368d2a 100644
--- a/Package.resolved
+++ b/Package.resolved
@@ -1,6 +1,14 @@
{
- "originHash" : "89e7e64237e35c358a14a47a205c0eef127e8091d722fa43cb04ca01f5e8f3e4",
+ "originHash" : "643ee9f1e86f0f8cc956f0b9542148281e53145a190653855e03c6e8f2d29c5b",
"pins" : [
+ {
+ "identity" : "codexkit",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/lynnswap/CodexKit.git",
+ "state" : {
+ "revision" : "6de9e019d35095390252102bd30b069e1090c874"
+ }
+ },
{
"identity" : "eventsource",
"kind" : "remoteSourceControl",
@@ -19,6 +27,15 @@
"version" : "0.12.0"
}
},
+ {
+ "identity" : "swift-async-algorithms",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-async-algorithms",
+ "state" : {
+ "revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272",
+ "version" : "1.1.3"
+ }
+ },
{
"identity" : "swift-atomics",
"kind" : "remoteSourceControl",
diff --git a/Package.swift b/Package.swift
index fc776fd0..1d276405 100644
--- a/Package.swift
+++ b/Package.swift
@@ -9,8 +9,8 @@ let package = Package(
],
products: [
.library(
- name: "CodexReview",
- targets: ["CodexReview"]
+ name: "CodexReviewKit",
+ targets: ["CodexReviewKit"]
),
.library(
name: "CodexReviewHost",
@@ -20,6 +20,10 @@ let package = Package(
name: "ReviewUI",
targets: ["ReviewUI"]
),
+ .library(
+ name: "ReviewUIPreviewSupport",
+ targets: ["ReviewUIPreviewSupport"]
+ ),
.library(
name: "TextTransitions",
targets: ["TextTransitions"]
@@ -29,60 +33,26 @@ let package = Package(
.package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", exact: "0.12.1"),
.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")),
+ // CodexKit has no tagged releases yet; pin the reviewed main revision
+ // (lynnswap/CodexKit#16 merge).
+ .package(url: "https://github.com/lynnswap/CodexKit.git", revision: "6de9e019d35095390252102bd30b069e1090c874"),
],
targets: [
.target(
- name: "CodexReviewDomain",
- swiftSettings: [
- .swiftLanguageMode(.v6),
- ]
- ),
- .target(
- name: "CodexReviewApplication",
+ name: "CodexReviewKit",
dependencies: [
- "CodexReviewDomain",
.product(name: "ObservationBridge", package: "ObservationBridge"),
],
swiftSettings: [
.swiftLanguageMode(.v6),
]
),
- .target(
- name: "CodexReview",
- dependencies: [
- "CodexReviewApplication",
- "CodexReviewDomain",
- .product(name: "ObservationBridge", package: "ObservationBridge"),
- ],
- swiftSettings: [
- .swiftLanguageMode(.v6),
- ]
- ),
- .target(
- name: "CodexReviewAppServerWire",
- dependencies: [
- "CodexReviewDomain",
- ],
- swiftSettings: [
- .swiftLanguageMode(.v6),
- ]
- ),
.target(
name: "CodexReviewAppServer",
dependencies: [
- "CodexReview",
- "CodexReviewAppServerWire",
- "CodexReviewDomain",
- ],
- swiftSettings: [
- .swiftLanguageMode(.v6),
- ]
- ),
- .target(
- name: "CodexReviewMCPAdapter",
- dependencies: [
- "CodexReview",
- "CodexReviewDomain",
+ .product(name: "CodexKit", package: "CodexKit"),
+ .product(name: "CodexAppServerKit", package: "CodexKit"),
+ "CodexReviewKit",
],
swiftSettings: [
.swiftLanguageMode(.v6),
@@ -91,8 +61,8 @@ let package = Package(
.target(
name: "CodexReviewMCPServer",
dependencies: [
- "CodexReview",
- "CodexReviewMCPAdapter",
+ .product(name: "CodexKit", package: "CodexKit"),
+ "CodexReviewKit",
.product(name: "MCP", package: "swift-sdk"),
.product(name: "NIOCore", package: "swift-nio"),
.product(name: "NIOHTTP1", package: "swift-nio"),
@@ -105,7 +75,9 @@ let package = Package(
.target(
name: "CodexReviewHost",
dependencies: [
- "CodexReview",
+ .product(name: "CodexKit", package: "CodexKit"),
+ .product(name: "CodexAppServerKit", package: "CodexKit"),
+ "CodexReviewKit",
"CodexReviewAppServer",
"CodexReviewMCPServer",
],
@@ -116,8 +88,9 @@ let package = Package(
.target(
name: "CodexReviewTesting",
dependencies: [
- "CodexReview",
- "CodexReviewAppServer",
+ .product(name: "CodexAppServerKit", package: "CodexKit"),
+ .product(name: "CodexAppServerKitTesting", package: "CodexKit"),
+ "CodexReviewKit",
],
swiftSettings: [
.swiftLanguageMode(.v6),
@@ -126,10 +99,9 @@ let package = Package(
.target(
name: "ReviewUI",
dependencies: [
- "CodexReview",
- "CodexReviewDomain",
- "ReviewMonitorRendering",
- "TextTransitions",
+ "CodexReviewKit",
+ "ReviewChatLogUI",
+ .product(name: "CodexKit", package: "CodexKit"),
.product(name: "ObservationBridge", package: "ObservationBridge"),
],
swiftSettings: [
@@ -137,73 +109,57 @@ let package = Package(
]
),
.target(
- name: "ReviewMonitorRendering",
+ name: "ReviewChatLogUI",
dependencies: [
- "CodexReviewDomain",
+ "TextTransitions",
+ .product(name: "CodexKit", package: "CodexKit"),
],
swiftSettings: [
.swiftLanguageMode(.v6),
]
),
.target(
- name: "TextTransitions",
- swiftSettings: [
- .swiftLanguageMode(.v6),
- ]
- ),
- .testTarget(
- name: "CodexReviewDomainTests",
+ name: "ReviewUIPreviewSupport",
dependencies: [
- "CodexReviewDomain",
+ .product(name: "CodexKit", package: "CodexKit"),
+ .product(name: "CodexAppServerKitTesting", package: "CodexKit"),
+ "CodexReviewKit",
+ "ReviewUI",
],
swiftSettings: [
.swiftLanguageMode(.v6),
]
),
- .testTarget(
- name: "CodexReviewApplicationTests",
- dependencies: [
- "CodexReviewApplication",
- ],
+ .target(
+ name: "TextTransitions",
swiftSettings: [
.swiftLanguageMode(.v6),
]
),
.testTarget(
- name: "CodexReviewAppServerWireTests",
- dependencies: [
- "CodexReviewAppServerWire",
- ],
+ name: "CodexReviewKitTests",
+ dependencies: ["CodexReviewKit", "CodexReviewTesting"],
swiftSettings: [
.swiftLanguageMode(.v6),
]
),
.testTarget(
- name: "CodexReviewMCPAdapterTests",
+ name: "CodexReviewAppServerTests",
dependencies: [
- "CodexReviewMCPAdapter",
+ .product(name: "CodexAppServerKit", package: "CodexKit"),
+ .product(name: "CodexAppServerKitTesting", package: "CodexKit"),
+ "CodexReviewAppServer",
+ "CodexReviewKit",
+ "CodexReviewTesting",
],
swiftSettings: [
.swiftLanguageMode(.v6),
]
),
- .testTarget(
- name: "CodexReviewTests",
- dependencies: ["CodexReview", "CodexReviewDomain", "CodexReviewTesting"],
- swiftSettings: [
- .swiftLanguageMode(.v6),
- ]
- ),
- .testTarget(
- name: "CodexReviewAppServerTests",
- dependencies: ["CodexReviewAppServer", "CodexReviewDomain", "CodexReviewTesting"],
- swiftSettings: [
- .swiftLanguageMode(.v6),
- ]
- ),
.testTarget(
name: "CodexReviewMCPServerTests",
dependencies: [
+ .product(name: "CodexKit", package: "CodexKit"),
"CodexReviewMCPServer",
"CodexReviewTesting",
.product(name: "NIOCore", package: "swift-nio"),
@@ -214,27 +170,27 @@ let package = Package(
),
.testTarget(
name: "CodexReviewHostTests",
- dependencies: ["CodexReviewAppServer", "CodexReviewHost", "CodexReviewTesting"],
- swiftSettings: [
- .swiftLanguageMode(.v6),
- ]
- ),
- .testTarget(
- name: "ReviewUITests",
dependencies: [
- "CodexReview",
+ .product(name: "CodexAppServerKit", package: "CodexKit"),
+ .product(name: "CodexAppServerKitTesting", package: "CodexKit"),
+ "CodexReviewAppServer",
+ "CodexReviewHost",
"CodexReviewTesting",
- "ReviewUI",
],
swiftSettings: [
.swiftLanguageMode(.v6),
]
),
.testTarget(
- name: "ReviewMonitorRenderingTests",
+ name: "ReviewUITests",
dependencies: [
- "CodexReviewDomain",
- "ReviewMonitorRendering",
+ .product(name: "CodexKit", package: "CodexKit"),
+ .product(name: "CodexAppServerKitTesting", package: "CodexKit"),
+ "CodexReviewKit",
+ "CodexReviewTesting",
+ "ReviewChatLogUI",
+ "ReviewUI",
+ "ReviewUIPreviewSupport",
],
swiftSettings: [
.swiftLanguageMode(.v6),
@@ -249,12 +205,6 @@ let package = Package(
.swiftLanguageMode(.v6),
]
),
- .testTarget(
- name: "ArchitectureFenceTests",
- swiftSettings: [
- .swiftLanguageMode(.v6),
- ]
- ),
],
swiftLanguageModes: [.v6]
)
diff --git a/README.md b/README.md
index 76c0abc3..779390a4 100644
--- a/README.md
+++ b/README.md
@@ -34,11 +34,40 @@ visible.
## What Runs Locally
-- `CodexReviewMonitor.app` shows review jobs, output, and findings.
+- `CodexReviewMonitor.app` shows review runs, CodexChat logs, and findings.
- `http://localhost:9417/mcp` is the app-managed MCP endpoint.
- `codex app-server` runs behind CodexReviewMonitor as the live review backend.
- `~/.codex_review` is the dedicated Codex home used by CodexReviewMonitor.
+## CodexAppServerKit
+
+`CodexAppServerKit` is the Swift library product for working with a local
+`codex app-server` process. It owns the stdio JSON-RPC transport, app-server
+handshake, typed request DTOs, and a domain-oriented public API for sessions,
+thread IDs, turn IDs, prompts, responses, response streams, transcripts, models,
+accounts, and login flows.
+
+The public API is centered on a `CodexAppServer` value that is initialized and
+kept 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 result = try await thread.respond(to: "Review this workspace.")
+await appServer.close()
+```
+
+`CodexReviewAppServer` builds on that lower-level app-server boundary and keeps
+only ReviewMonitor-specific `review/start` orchestration and review event
+conversion.
+
+See [CodexAppServerKit README][codex-app-server-kit-readme] 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.
+
## Timeout Setup
Long reviews can exceed the default MCP client timeout. `codex mcp add` does
@@ -88,3 +117,5 @@ 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
diff --git a/Sources/CodexReview/CodexReviewExports.swift b/Sources/CodexReview/CodexReviewExports.swift
deleted file mode 100644
index 43a7b0f0..00000000
--- a/Sources/CodexReview/CodexReviewExports.swift
+++ /dev/null
@@ -1,2 +0,0 @@
-@_exported import CodexReviewApplication
-@_exported import CodexReviewDomain
diff --git a/Sources/CodexReview/CodexReviewTypes.swift b/Sources/CodexReview/CodexReviewTypes.swift
deleted file mode 100644
index a85f3814..00000000
--- a/Sources/CodexReview/CodexReviewTypes.swift
+++ /dev/null
@@ -1,337 +0,0 @@
-import Foundation
-import CodexReviewDomain
-
-package enum CodexReviewBackendModel {
- package enum Settings {}
- package enum Account {}
- package enum Auth {}
- package enum Login {}
- package enum Review {}
-}
-
-package extension CodexReviewBackendModel.Settings {
-struct Snapshot: Codable, Equatable, Sendable {
- package var model: String?
- package var fallbackModel: String?
- package var reasoningEffort: String?
- package var serviceTier: String?
- package var models: [CodexReviewSettings.ModelCatalogItem]
-
- package init(
- model: String? = nil,
- fallbackModel: String? = nil,
- reasoningEffort: String? = nil,
- serviceTier: String? = nil,
- models: [CodexReviewSettings.ModelCatalogItem] = []
- ) {
- self.model = model
- self.fallbackModel = fallbackModel
- self.reasoningEffort = reasoningEffort
- self.serviceTier = serviceTier
- self.models = models
- }
-}
-}
-
-
-package extension CodexReviewBackendModel.Settings {
-struct Change: Codable, Equatable, Sendable {
- package var model: String?
- package var reasoningEffort: String?
- package var serviceTier: String?
- package var updatesModel: Bool
- package var updatesReasoningEffort: Bool
- package var updatesServiceTier: Bool
-
- package init(
- model: String? = nil,
- reasoningEffort: String? = nil,
- serviceTier: String? = nil,
- updatesModel: Bool? = nil,
- updatesReasoningEffort: Bool? = nil,
- updatesServiceTier: Bool? = nil
- ) {
- self.model = model
- self.reasoningEffort = reasoningEffort
- self.serviceTier = serviceTier
- self.updatesModel = updatesModel ?? (model != nil)
- self.updatesReasoningEffort = updatesReasoningEffort ?? (reasoningEffort != nil)
- self.updatesServiceTier = updatesServiceTier ?? (serviceTier != nil)
- }
-}
-}
-
-
-package extension CodexReviewBackendModel.Account {
-struct ID: Codable, Hashable, Sendable {
- package var rawValue: String
-
- package init(_ rawValue: String) {
- self.rawValue = rawValue
- }
-}
-}
-
-
-package extension CodexReviewBackendModel.Account {
-enum Kind: String, Codable, Equatable, Sendable {
- case chatGPT = "chatgpt"
- case apiKey
- case amazonBedrock
-}
-}
-
-
-package extension CodexReviewBackendModel.Account {
-struct Capabilities: Codable, Equatable, Sendable {
- package var supportsRateLimitRefresh: Bool
-
- package init(supportsRateLimitRefresh: Bool = true) {
- self.supportsRateLimitRefresh = supportsRateLimitRefresh
- }
-
- package static var supportsCodexRateLimits: Self {
- .init(supportsRateLimitRefresh: true)
- }
-
- package static var noCodexRateLimits: Self {
- .init(supportsRateLimitRefresh: false)
- }
-}
-}
-
-
-package extension CodexReviewBackendModel.Account.Kind {
- var capabilities: CodexReviewBackendModel.Account.Capabilities {
- switch self {
- case .chatGPT:
- .supportsCodexRateLimits
- case .apiKey, .amazonBedrock:
- .noCodexRateLimits
- }
- }
-}
-
-
-package extension CodexReviewBackendModel.Account {
-struct Snapshot: Codable, Equatable, Sendable, Identifiable {
- package var id: CodexReviewBackendModel.Account.ID
- package var kind: CodexReviewBackendModel.Account.Kind
- package var label: String
- package var isActive: Bool
- package var planType: String?
- package var capabilities: CodexReviewBackendModel.Account.Capabilities
-
- package init(
- id: CodexReviewBackendModel.Account.ID,
- kind: CodexReviewBackendModel.Account.Kind = .chatGPT,
- label: String,
- isActive: Bool = false,
- planType: String? = nil,
- capabilities: CodexReviewBackendModel.Account.Capabilities? = nil
- ) {
- self.id = id
- self.kind = kind
- self.label = label
- self.isActive = isActive
- self.planType = planType
- self.capabilities = capabilities ?? kind.capabilities
- }
-}
-}
-
-
-package extension CodexReviewBackendModel.Auth {
-struct Snapshot: Codable, Equatable, Sendable {
- package var accounts: [CodexReviewBackendModel.Account.Snapshot]
- package var activeAccountID: CodexReviewBackendModel.Account.ID?
-
- package init(
- accounts: [CodexReviewBackendModel.Account.Snapshot] = [],
- activeAccountID: CodexReviewBackendModel.Account.ID? = nil
- ) {
- self.accounts = accounts
- self.activeAccountID = activeAccountID
- }
-}
-}
-
-
-package extension CodexReviewBackendModel.Auth {
-enum Phase: Codable, Equatable, Sendable {
- case unknown
- case signedOut
- case authenticated
- case authenticating(challengeID: String)
- case failed(message: String)
-}
-}
-
-
-package extension CodexReviewBackendModel.Login {
-struct Request: Codable, Equatable, Sendable {
- package var preferredAccountID: CodexReviewBackendModel.Account.ID?
- package var nativeWebAuthenticationCallbackScheme: String?
-
- package init(
- preferredAccountID: CodexReviewBackendModel.Account.ID? = nil,
- nativeWebAuthenticationCallbackScheme: String? = nil
- ) {
- self.preferredAccountID = preferredAccountID
- self.nativeWebAuthenticationCallbackScheme = nativeWebAuthenticationCallbackScheme
- }
-}
-}
-
-
-package extension CodexReviewBackendModel.Login {
-struct Challenge: Codable, Equatable, Sendable {
- package var id: String
- package var verificationURL: URL?
- package var userCode: String?
- package var nativeWebAuthenticationCallbackScheme: String?
-
- package init(
- id: String,
- verificationURL: URL? = nil,
- userCode: String? = nil,
- nativeWebAuthenticationCallbackScheme: String? = nil
- ) {
- self.id = id
- self.verificationURL = verificationURL
- self.userCode = userCode
- self.nativeWebAuthenticationCallbackScheme = nativeWebAuthenticationCallbackScheme
- }
-}
-}
-
-
-package extension CodexReviewBackendModel.Login {
-struct Response: Codable, Equatable, Sendable {
- package var challengeID: String
- package var callbackURL: String?
-
- package init(challengeID: String, callbackURL: String? = nil) {
- self.challengeID = challengeID
- self.callbackURL = callbackURL
- }
-}
-}
-
-
-package extension CodexReviewBackendModel.Review {
-struct Start: Equatable, Sendable {
- package var jobID: String
- package var sessionID: String
- package var request: CodexReviewAPI.Start.Request
- package var model: String?
-
- package init(jobID: String, sessionID: String, request: CodexReviewAPI.Start.Request) {
- self.init(jobID: jobID, sessionID: sessionID, request: request, model: nil)
- }
-
- package init(
- jobID: String,
- sessionID: String,
- request: CodexReviewAPI.Start.Request,
- model: String?
- ) {
- self.jobID = jobID
- self.sessionID = sessionID
- self.request = request
- self.model = model
- }
-}
-}
-
-
-package extension CodexReviewBackendModel.Review {
-struct Run: Codable, Equatable, Sendable {
- package var attemptID: String
- package var threadID: String
- package var turnID: String?
- package var reviewThreadID: String?
- package var model: String?
-
- enum CodingKeys: String, CodingKey {
- case attemptID
- case threadID
- case turnID
- case reviewThreadID
- case model
- }
-
- package init(
- attemptID: String = "attempt-1",
- threadID: String,
- turnID: String? = nil,
- reviewThreadID: String? = nil,
- model: String? = nil
- ) {
- self.attemptID = attemptID
- self.threadID = threadID
- self.turnID = turnID
- self.reviewThreadID = reviewThreadID
- self.model = model
- }
-
- package init(from decoder: Decoder) throws {
- let container = try decoder.container(keyedBy: CodingKeys.self)
- self.attemptID = try container.decodeIfPresent(String.self, forKey: .attemptID) ?? "attempt-1"
- self.threadID = try container.decode(String.self, forKey: .threadID)
- self.turnID = try container.decodeIfPresent(String.self, forKey: .turnID)
- self.reviewThreadID = try container.decodeIfPresent(String.self, forKey: .reviewThreadID)
- self.model = try container.decodeIfPresent(String.self, forKey: .model)
- }
-}
-}
-
-
-package extension CodexReviewBackendModel.Review {
-struct RecoveryToken: Equatable, Sendable {
- package var interruptedRun: CodexReviewBackendModel.Review.Run
- package var rollbackThreadID: String
-
- package init(
- interruptedRun: CodexReviewBackendModel.Review.Run,
- rollbackThreadID: String
- ) {
- self.interruptedRun = interruptedRun
- self.rollbackThreadID = rollbackThreadID
- }
-}
-}
-
-
-package extension CodexReviewBackendModel.Review {
-enum Event: Equatable, Sendable {
- case domainEvents([ReviewDomainEvent], legacyProjectionSuppressionCount: Int)
- case suppressNextLegacyTimelineProjection
- case suppressNextTerminalFailureLogTimelineProjection
- case started(turnID: String, reviewThreadID: String?, model: String?)
- case message(String)
- case messageDelta(String, itemID: String)
- case log(String)
- case logEntry(
- kind: ReviewLogEntry.Kind,
- text: String,
- groupID: String?,
- replacesGroup: Bool,
- metadata: ReviewLogEntry.Metadata? = nil
- )
- case completed(summary: String, result: String?)
- case failed(String)
- case cancelled(String)
-}
-}
-
-
-package extension CodexReviewBackendModel {
-struct CancellationReason: Codable, Equatable, Sendable {
- package var message: String
-
- package init(message: String = "Cancellation requested.") {
- self.message = message
- }
-}
-}
diff --git a/Sources/CodexReview/Model/CodexReviewJob.swift b/Sources/CodexReview/Model/CodexReviewJob.swift
deleted file mode 100644
index 62585b11..00000000
--- a/Sources/CodexReview/Model/CodexReviewJob.swift
+++ /dev/null
@@ -1,1334 +0,0 @@
-import Foundation
-import Observation
-import CodexReviewDomain
-
-@MainActor
-@Observable
-public final class CodexReviewJob: Identifiable, Hashable {
- package enum TruncationDirection {
- case prefix
- case suffix
- }
-
- private struct GroupKey: Hashable {
- var kind: ReviewLogEntry.Kind
- var groupID: String
- }
-
- private struct RenderedBlock {
- var kind: ReviewLogEntry.Kind
- var groupID: String?
- var text: String
- }
-
- package enum LogMutation: Sendable, Equatable {
- case reload
- case append
- }
-
- private struct ProjectionAccumulator {
- enum JoinMode {
- case rendered
- case rawLines
- }
-
- let joinMode: JoinMode
- private(set) var text = ""
- private(set) var hasVisibleSections = false
- private(set) var lastBlockIndex: Int?
-
- mutating func appendSection(_ section: String, at blockIndex: Int) -> String {
- let appended: String
- if hasVisibleSections == false {
- text = section
- hasVisibleSections = true
- lastBlockIndex = blockIndex
- return section
- }
-
- switch joinMode {
- case .rawLines:
- appended = "\n" + section
- case .rendered:
- if section.isEmpty {
- appended = "\n\n"
- } else if text.hasSuffix("\n\n") {
- appended = section
- } else if text.hasSuffix("\n") || section.hasPrefix("\n") {
- appended = "\n" + section
- } else {
- appended = "\n\n" + section
- }
- }
-
- text += appended
- lastBlockIndex = blockIndex
- return appended
- }
-
- mutating func appendToCurrentSection(_ suffix: String) {
- guard suffix.isEmpty == false else {
- return
- }
- text += suffix
- }
- }
-
- private struct LogState {
- var blocks: [RenderedBlock]
- var indexByGroup: [GroupKey: Int]
- var logProjection: ProjectionAccumulator
- var reviewOutputProjection: ProjectionAccumulator
- var activityProjection: ProjectionAccumulator
- var errorProjection: ProjectionAccumulator
- var rawProjection: ProjectionAccumulator
- var cappedProjection: ProjectionAccumulator
-
- init(
- blocks: [RenderedBlock],
- indexByGroup: [GroupKey: Int],
- logProjection: ProjectionAccumulator,
- reviewOutputProjection: ProjectionAccumulator,
- activityProjection: ProjectionAccumulator,
- errorProjection: ProjectionAccumulator,
- rawProjection: ProjectionAccumulator,
- cappedProjection: ProjectionAccumulator
- ) {
- self.blocks = blocks
- self.indexByGroup = indexByGroup
- self.logProjection = logProjection
- self.reviewOutputProjection = reviewOutputProjection
- self.activityProjection = activityProjection
- self.errorProjection = errorProjection
- self.rawProjection = rawProjection
- self.cappedProjection = cappedProjection
- }
-
- init(entries: [ReviewLogEntry]) {
- self = Self.rebuild(entries: entries)
- }
-
- var logText: String {
- logProjection.text
- }
-
- var rawLogText: String {
- rawProjection.text
- }
-
- var reviewOutputText: String {
- reviewOutputProjection.text
- }
-
- var activityLogText: String {
- activityProjection.text
- }
-
- var diagnosticText: String {
- CodexReviewJob.combinedText(
- sections: [
- errorProjection.text,
- rawProjection.text,
- ]
- )
- }
-
- var cappedBytes: Int {
- cappedProjection.text.utf8.count
- }
-
- static func rebuild(entries: [ReviewLogEntry]) -> LogState {
- var state = LogState(
- blocks: [],
- indexByGroup: [:],
- logProjection: .init(joinMode: .rendered),
- reviewOutputProjection: .init(joinMode: .rendered),
- activityProjection: .init(joinMode: .rendered),
- errorProjection: .init(joinMode: .rendered),
- rawProjection: .init(joinMode: .rawLines),
- cappedProjection: .init(joinMode: .rendered)
- )
-
- for entry in entries {
- if let key = CodexReviewJob.mergeKey(for: entry) {
- if let index = state.indexByGroup[key] {
- if entry.replacesGroup {
- state.blocks[index].text = entry.text
- } else {
- state.blocks[index].text.append(entry.text)
- }
- continue
- }
- state.indexByGroup[key] = state.blocks.count
- }
-
- state.blocks.append(.init(
- kind: entry.kind,
- groupID: entry.groupID,
- text: entry.text
- ))
- }
-
- for (index, block) in state.blocks.enumerated() {
- state.ingestBlock(block, at: index)
- }
- return state
- }
-
- func supportsIncrementalAppend(_ entry: ReviewLogEntry) -> Bool {
- guard entry.replacesGroup == false,
- let key = CodexReviewJob.mergeKey(for: entry),
- let blockIndex = indexByGroup[key]
- else {
- return entry.replacesGroup == false
- }
- return blockIndex == blocks.indices.last
- }
-
- mutating func append(_ entry: ReviewLogEntry) {
- if let key = CodexReviewJob.mergeKey(for: entry) {
- if let blockIndex = indexByGroup[key] {
- let oldText = blocks[blockIndex].text
- precondition(entry.replacesGroup == false && blockIndex == blocks.indices.last)
-
- blocks[blockIndex].text.append(entry.text)
- let newText = blocks[blockIndex].text
- appendTailGroupDelta(
- block: blocks[blockIndex],
- oldText: oldText,
- newText: newText,
- blockIndex: blockIndex,
- delta: entry.text
- )
- return
- }
-
- indexByGroup[key] = blocks.count
- }
-
- let blockIndex = blocks.count
- let block = RenderedBlock(
- kind: entry.kind,
- groupID: entry.groupID,
- text: entry.text
- )
- blocks.append(block)
- appendTailBlock(block, at: blockIndex)
- }
-
- private mutating func ingestBlock(_ block: RenderedBlock, at index: Int) {
- _ = Self.updateRenderedProjection(
- &logProjection,
- block: block,
- blockIndex: index,
- visibleKinds: CodexReviewJob.displayedLogKinds,
- includeEmptyDiagnostic: true
- )
- _ = Self.updateRenderedProjection(
- &reviewOutputProjection,
- block: block,
- blockIndex: index,
- visibleKinds: CodexReviewJob.reviewOutputKinds,
- includeEmptyDiagnostic: false
- )
- _ = Self.updateRenderedProjection(
- &activityProjection,
- block: block,
- blockIndex: index,
- visibleKinds: CodexReviewJob.activityKinds,
- includeEmptyDiagnostic: false
- )
- _ = Self.updateRenderedProjection(
- &errorProjection,
- block: block,
- blockIndex: index,
- visibleKinds: [.error],
- includeEmptyDiagnostic: false
- )
- _ = Self.updateRenderedProjection(
- &cappedProjection,
- block: block,
- blockIndex: index,
- visibleKinds: CodexReviewJob.cappedLogKinds,
- includeEmptyDiagnostic: false
- )
- if block.kind == .diagnostic {
- _ = rawProjection.appendSection(block.text, at: index)
- }
- }
-
- private mutating func appendTailBlock(
- _ block: RenderedBlock,
- at blockIndex: Int
- ) {
- _ = Self.updateRenderedProjection(
- &logProjection,
- block: block,
- blockIndex: blockIndex,
- visibleKinds: CodexReviewJob.displayedLogKinds,
- includeEmptyDiagnostic: true
- )
- _ = Self.updateRenderedProjection(
- &reviewOutputProjection,
- block: block,
- blockIndex: blockIndex,
- visibleKinds: CodexReviewJob.reviewOutputKinds,
- includeEmptyDiagnostic: false
- )
- _ = Self.updateRenderedProjection(
- &activityProjection,
- block: block,
- blockIndex: blockIndex,
- visibleKinds: CodexReviewJob.activityKinds,
- includeEmptyDiagnostic: false
- )
- _ = Self.updateRenderedProjection(
- &errorProjection,
- block: block,
- blockIndex: blockIndex,
- visibleKinds: [.error],
- includeEmptyDiagnostic: false
- )
- _ = Self.updateRenderedProjection(
- &cappedProjection,
- block: block,
- blockIndex: blockIndex,
- visibleKinds: CodexReviewJob.cappedLogKinds,
- includeEmptyDiagnostic: false
- )
- if block.kind == .diagnostic {
- _ = rawProjection.appendSection(block.text, at: blockIndex)
- }
- }
-
- private mutating func appendTailGroupDelta(
- block: RenderedBlock,
- oldText: String,
- newText: String,
- blockIndex: Int,
- delta: String
- ) {
- _ = Self.updateTailProjection(
- &logProjection,
- kind: block.kind,
- oldText: oldText,
- newText: newText,
- blockIndex: blockIndex,
- delta: delta,
- visibleKinds: CodexReviewJob.displayedLogKinds,
- includeEmptyDiagnostic: true
- )
- _ = Self.updateTailProjection(
- &reviewOutputProjection,
- kind: block.kind,
- oldText: oldText,
- newText: newText,
- blockIndex: blockIndex,
- delta: delta,
- visibleKinds: CodexReviewJob.reviewOutputKinds,
- includeEmptyDiagnostic: false
- )
- _ = Self.updateTailProjection(
- &activityProjection,
- kind: block.kind,
- oldText: oldText,
- newText: newText,
- blockIndex: blockIndex,
- delta: delta,
- visibleKinds: CodexReviewJob.activityKinds,
- includeEmptyDiagnostic: false
- )
- _ = Self.updateTailProjection(
- &errorProjection,
- kind: block.kind,
- oldText: oldText,
- newText: newText,
- blockIndex: blockIndex,
- delta: delta,
- visibleKinds: [.error],
- includeEmptyDiagnostic: false
- )
- _ = Self.updateTailProjection(
- &cappedProjection,
- kind: block.kind,
- oldText: oldText,
- newText: newText,
- blockIndex: blockIndex,
- delta: delta,
- visibleKinds: CodexReviewJob.cappedLogKinds,
- includeEmptyDiagnostic: false
- )
- }
-
- private static func updateTailProjection(
- _ projection: inout ProjectionAccumulator,
- kind: ReviewLogEntry.Kind,
- oldText: String,
- newText: String,
- blockIndex: Int,
- delta: String,
- visibleKinds: Set,
- includeEmptyDiagnostic: Bool
- ) -> String? {
- let wasVisible = CodexReviewJob.isVisibleInRenderedProjection(
- kind: kind,
- text: oldText,
- visibleKinds: visibleKinds,
- includeEmptyDiagnostic: includeEmptyDiagnostic
- )
- let isVisible = CodexReviewJob.isVisibleInRenderedProjection(
- kind: kind,
- text: newText,
- visibleKinds: visibleKinds,
- includeEmptyDiagnostic: includeEmptyDiagnostic
- )
-
- switch (wasVisible, isVisible) {
- case (false, false):
- return nil
- case (false, true):
- return projection.appendSection(newText, at: blockIndex)
- case (true, true):
- projection.appendToCurrentSection(delta)
- return delta
- case (true, false):
- return nil
- }
- }
-
- private static func updateRenderedProjection(
- _ projection: inout ProjectionAccumulator,
- block: RenderedBlock,
- blockIndex: Int,
- visibleKinds: Set,
- includeEmptyDiagnostic: Bool
- ) -> String? {
- guard CodexReviewJob.isVisibleInRenderedProjection(
- kind: block.kind,
- text: block.text,
- visibleKinds: visibleKinds,
- includeEmptyDiagnostic: includeEmptyDiagnostic
- ) else {
- return nil
- }
- return projection.appendSection(block.text, at: blockIndex)
- }
- }
-
- private struct TrimmedLogState {
- var entries: [ReviewLogEntry]
- var logState: LogState
- }
-
- @ObservationIgnored
- private var logState: LogState
-
- public nonisolated let id: String
- public let domainJob: ReviewJob
- public var timeline: ReviewTimeline {
- domainJob.timeline
- }
- public let sessionID: String
- public let cwd: String
- public internal(set) var sortOrder: Double
- public internal(set) var targetSummary: String
- public internal(set) var core: ReviewJobCore
- public internal(set) var cancellationRequested: Bool
- @ObservationIgnored
- package var agentMessagesByItemID: [String: String]
- @ObservationIgnored
- package var completedAgentMessageItemIDs: Set
- @ObservationIgnored
- package private(set) var usesDirectTimelineEvents: Bool
- @ObservationIgnored
- private var pendingLegacyTimelineProjectionSuppressions: Int
- @ObservationIgnored
- private var pendingTerminalFailureLogTimelineProjectionSuppressions: Int
- @ObservationIgnored
- package var directTimelineTextItemIDs: Set
- @ObservationIgnored
- package var directTimelineTextItemIDsWithCompatibilityLog: Set
- @ObservationIgnored
- package var directTimelineTextCompatibilityItemIDsByLogEntryID: [ReviewLogEntry.ID: Set]
- @ObservationIgnored
- private var pendingDirectTimelineTextItemIDsForCompatibilityLog: [ReviewTimelineItem.ID]
- @ObservationIgnored
- private var latestDirectTimelineTextItemIDs: [ReviewTimelineItem.ID]
- @ObservationIgnored
- package var legacyProjectedTimelineTextItemIDs: Set
- public private(set) var logEntries: [ReviewLogEntry]
- public private(set) var logText: String
- public private(set) var rawLogText: String
- public private(set) var reviewOutputText: String
- public private(set) var activityLogText: String
- public private(set) var diagnosticText: String
- package private(set) var cappedLogBytes: Int
- package private(set) var logRevision: UInt64
- @ObservationIgnored
- package private(set) var lastLogMutation: LogMutation
-
- public var isTerminal: Bool {
- core.isTerminal
- }
-
- public var displayTitle: String {
- targetSummary
- }
-
- public var reviewText: String {
- core.reviewText
- }
-
- package init(
- id: String,
- sessionID: String,
- cwd: String,
- sortOrder: Double = 0,
- targetSummary: String,
- core: ReviewJobCore,
- cancellationRequested: Bool = false,
- logEntries: [ReviewLogEntry]
- ) {
- let initialState = Self.trimmedLogState(entries: logEntries)
- self.id = id
- self.domainJob = ReviewJob(id: .init(rawValue: id))
- self.sessionID = sessionID
- self.cwd = cwd
- self.sortOrder = sortOrder
- self.targetSummary = targetSummary
- self.core = core
- self.cancellationRequested = cancellationRequested
- self.agentMessagesByItemID = [:]
- self.completedAgentMessageItemIDs = []
- self.usesDirectTimelineEvents = false
- self.pendingLegacyTimelineProjectionSuppressions = 0
- self.pendingTerminalFailureLogTimelineProjectionSuppressions = 0
- self.directTimelineTextItemIDs = []
- self.directTimelineTextItemIDsWithCompatibilityLog = []
- self.directTimelineTextCompatibilityItemIDsByLogEntryID = [:]
- self.pendingDirectTimelineTextItemIDsForCompatibilityLog = []
- self.latestDirectTimelineTextItemIDs = []
- self.legacyProjectedTimelineTextItemIDs = []
- self.logState = initialState.logState
- self.logEntries = initialState.entries
- self.logText = initialState.logState.logText
- self.rawLogText = initialState.logState.rawLogText
- self.reviewOutputText = initialState.logState.reviewOutputText
- self.activityLogText = initialState.logState.activityLogText
- self.diagnosticText = initialState.logState.diagnosticText
- self.cappedLogBytes = initialState.logState.cappedBytes
- self.logRevision = 0
- self.lastLogMutation = .reload
- if usesDirectTimelineEvents == false {
- rebuildTimelineFromLogEntries()
- }
- }
-
- public nonisolated static func == (lhs: CodexReviewJob, rhs: CodexReviewJob) -> Bool {
- lhs.id == rhs.id
- }
-
- public nonisolated func hash(into hasher: inout Hasher) {
- hasher.combine(id)
- }
-
- package func replaceLogEntries(_ entries: [ReviewLogEntry], resetDirectTimeline: Bool = false) {
- let normalizedEntries = entries.map {
- $0.clampingUnownedRetainedMetadata(maxBytes: Self.logLimitBytes)
- }
- let trimmedState = Self.trimmedLogState(entries: normalizedEntries)
- logEntries = trimmedState.entries
- logState = trimmedState.logState
- if resetDirectTimeline {
- usesDirectTimelineEvents = false
- pendingLegacyTimelineProjectionSuppressions = 0
- pendingTerminalFailureLogTimelineProjectionSuppressions = 0
- directTimelineTextItemIDs.removeAll(keepingCapacity: true)
- directTimelineTextItemIDsWithCompatibilityLog.removeAll(keepingCapacity: true)
- directTimelineTextCompatibilityItemIDsByLogEntryID.removeAll(keepingCapacity: true)
- pendingDirectTimelineTextItemIDsForCompatibilityLog.removeAll(keepingCapacity: true)
- latestDirectTimelineTextItemIDs.removeAll(keepingCapacity: true)
- legacyProjectedTimelineTextItemIDs.removeAll(keepingCapacity: true)
- }
- if usesDirectTimelineEvents {
- trimTimelineTextContentToLogEntries()
- } else {
- rebuildTimelineFromLogEntries()
- }
- syncLogState(mutation: .reload)
- }
-
- package func appendLogEntry(_ entry: ReviewLogEntry, suppressTimelineProjection: Bool = false) {
- let entry = entry.clampingUnownedRetainedMetadata(maxBytes: Self.logLimitBytes)
- let supportsIncrementalAppend = logState.supportsIncrementalAppend(entry)
- if supportsIncrementalAppend {
- logState.append(entry)
- }
- logEntries.append(entry)
- if supportsIncrementalAppend == false {
- logState = LogState(entries: logEntries)
- }
- if usesDirectTimelineEvents, entry.canProvideDirectTimelineText {
- let allowsPendingFallback = suppressTimelineProjection || pendingLegacyTimelineProjectionSuppressions > 0
- if entry.retainedTimelineText != nil {
- recordDirectTimelineTextCompatibilityLog(
- for: entry,
- allowsPendingFallback: allowsPendingFallback
- )
- } else {
- discardDirectTimelineTextCompatibilityLog(
- for: entry,
- allowsPendingFallback: allowsPendingFallback
- )
- }
- }
- if suppressTimelineProjection == false,
- consumeLegacyTimelineProjectionSuppression() == false {
- applyTimelineEntry(entry)
- if usesDirectTimelineEvents, entry.canProvideDirectTimelineText {
- legacyProjectedTimelineTextItemIDs.insert(entry.timelineItemID)
- }
- }
- let didTrim = core.lifecycle.status.isTerminal ? trimReviewLogToLimit() : false
- if didTrim {
- rebuildTimelineFromLogEntries()
- }
- syncLogState(mutation: didTrim || supportsIncrementalAppend == false ? .reload : .append)
- }
-
- package func closeActiveCommandLogEntries(status: String, completedAt: Date) {
- let replacements = Self.activeCommandClosingEntries(
- entries: logEntries,
- status: status,
- completedAt: completedAt
- )
- for replacement in replacements {
- appendLogEntry(replacement)
- }
- }
-
- package func applyDirectTimelineEvents(
- _ events: [ReviewDomainEvent],
- legacyProjectionSuppressionCount: Int,
- at timestamp: Date
- ) {
- let directEvents = events.filter {
- $0.appliesFromDirectTimelineSource && shouldApplyDirectTimelineEvent($0)
- }
- guard directEvents.isEmpty == false else {
- return
- }
- if usesDirectTimelineEvents == false {
- recordLegacyProjectedTimelineTextItemIDsFromLogEntries()
- }
- usesDirectTimelineEvents = true
- pendingLegacyTimelineProjectionSuppressions += max(0, legacyProjectionSuppressionCount)
- var directTextItemIDsApplied: [ReviewTimelineItem.ID] = []
- for event in directEvents {
- if let itemID = event.directTimelineTextItemID {
- directTimelineTextItemIDs.insert(itemID)
- directTextItemIDsApplied.append(itemID)
- }
- timeline.apply(event, at: timestamp)
- }
- latestDirectTimelineTextItemIDs = directTextItemIDsApplied
- if legacyProjectionSuppressionCount > 0 {
- appendPendingDirectTimelineTextItemIDsForCompatibilityLog(directTextItemIDsApplied)
- }
- }
-
- private func shouldApplyDirectTimelineEvent(_ event: ReviewDomainEvent) -> Bool {
- switch event {
- case .textDelta(let itemID, _, let family, _, _)
- where family == .message && completedAgentMessageItemIDs.contains(itemID.rawValue):
- false
- default:
- true
- }
- }
-
- private func recordDirectTimelineTextCompatibilityLog(
- for entry: ReviewLogEntry,
- allowsPendingFallback: Bool
- ) {
- var itemIDs: Set = []
- for itemID in entry.directTimelineTextCandidateIDs where directTimelineTextItemIDs.contains(itemID) {
- itemIDs.insert(itemID)
- }
- if itemIDs.isEmpty,
- allowsPendingFallback,
- let pendingItemID = popPendingDirectTimelineTextItemIDForCompatibilityLog() {
- itemIDs.insert(pendingItemID)
- }
- guard itemIDs.isEmpty == false else {
- return
- }
- directTimelineTextItemIDsWithCompatibilityLog.formUnion(itemIDs)
- directTimelineTextCompatibilityItemIDsByLogEntryID[entry.id, default: []].formUnion(itemIDs)
- removePendingDirectTimelineTextItemIDsForCompatibilityLog(itemIDs)
- }
-
- private func discardDirectTimelineTextCompatibilityLog(
- for entry: ReviewLogEntry,
- allowsPendingFallback: Bool
- ) {
- var itemIDs: Set = []
- for itemID in entry.directTimelineTextCandidateIDs where directTimelineTextItemIDs.contains(itemID) {
- itemIDs.insert(itemID)
- }
- if itemIDs.isEmpty,
- allowsPendingFallback,
- let pendingItemID = popPendingDirectTimelineTextItemIDForCompatibilityLog() {
- itemIDs.insert(pendingItemID)
- }
- removePendingDirectTimelineTextItemIDsForCompatibilityLog(itemIDs)
- }
-
- private func appendPendingDirectTimelineTextItemIDsForCompatibilityLog(_ itemIDs: [ReviewTimelineItem.ID]) {
- for itemID in itemIDs where pendingDirectTimelineTextItemIDsForCompatibilityLog.contains(itemID) == false {
- pendingDirectTimelineTextItemIDsForCompatibilityLog.append(itemID)
- }
- }
-
- private func popPendingDirectTimelineTextItemIDForCompatibilityLog() -> ReviewTimelineItem.ID? {
- guard pendingDirectTimelineTextItemIDsForCompatibilityLog.isEmpty == false else {
- return nil
- }
- return pendingDirectTimelineTextItemIDsForCompatibilityLog.removeFirst()
- }
-
- private func removePendingDirectTimelineTextItemIDsForCompatibilityLog(_ itemIDs: Set) {
- guard itemIDs.isEmpty == false else {
- return
- }
- pendingDirectTimelineTextItemIDsForCompatibilityLog.removeAll { itemIDs.contains($0) }
- }
-
- private func recordLegacyProjectedTimelineTextItemIDsFromLogEntries() {
- for entry in logEntries where entry.retainedTimelineText != nil {
- let itemID = entry.timelineItemID
- if timeline.item(for: itemID) != nil {
- legacyProjectedTimelineTextItemIDs.insert(itemID)
- }
- }
- }
-
- package func suppressNextLegacyTimelineProjection() {
- pendingLegacyTimelineProjectionSuppressions += 1
- appendPendingDirectTimelineTextItemIDsForCompatibilityLog(latestDirectTimelineTextItemIDs)
- }
-
- package func suppressNextTerminalFailureLogTimelineProjection() {
- pendingTerminalFailureLogTimelineProjectionSuppressions += 1
- appendPendingDirectTimelineTextItemIDsForCompatibilityLog(latestDirectTimelineTextItemIDs)
- }
-
- package func discardPendingLegacyTimelineProjectionSuppression() {
- _ = consumeLegacyTimelineProjectionSuppression()
- }
-
- package func consumeTerminalFailureLogTimelineProjectionSuppression() -> Bool {
- guard pendingTerminalFailureLogTimelineProjectionSuppressions > 0 else {
- return false
- }
- pendingTerminalFailureLogTimelineProjectionSuppressions -= 1
- return true
- }
-
- private func consumeLegacyTimelineProjectionSuppression() -> Bool {
- guard pendingLegacyTimelineProjectionSuppressions > 0 else {
- return false
- }
- pendingLegacyTimelineProjectionSuppressions -= 1
- return true
- }
-
- @discardableResult
- package func applyReviewLogLimit() -> Bool {
- guard trimReviewLogToLimit() else {
- return false
- }
- syncLogState(mutation: .reload)
- return true
- }
-
- @discardableResult
- private func trimReviewLogToLimit() -> Bool {
- guard logState.cappedBytes > Self.logLimitBytes else {
- return false
- }
-
- let trimmedState = Self.trimmedLogState(entries: logEntries)
- guard trimmedState.entries != logEntries else {
- return false
- }
- logEntries = trimmedState.entries
- logState = trimmedState.logState
- if usesDirectTimelineEvents {
- trimTimelineTextContentToLogEntries()
- } else {
- rebuildTimelineFromLogEntries()
- }
- return true
- }
-
- package func truncateOrRemoveEntry(
- at index: Int,
- keeping direction: TruncationDirection,
- overflowBytes: Int
- ) {
- var entries = logEntries
- guard entries.indices.contains(index) else {
- return
- }
-
- let entry = entries[index]
- let retainedBytes = max(0, entry.text.utf8.count - overflowBytes)
- let truncatedText = switch direction {
- case .prefix:
- Self.truncateTextKeepingUTF8Prefix(entry.text, bytes: retainedBytes)
- case .suffix:
- Self.truncateTextKeepingUTF8Suffix(entry.text, bytes: retainedBytes)
- }
-
- if truncatedText.isEmpty {
- entries.remove(at: index)
- } else {
- entries[index] = .init(
- id: entry.id,
- kind: entry.kind,
- groupID: entry.groupID,
- replacesGroup: entry.replacesGroup,
- text: truncatedText,
- metadata: entry.metadata,
- timestamp: entry.timestamp
- )
- }
- replaceLogEntries(entries)
- }
-
- private func syncLogState(mutation: LogMutation) {
- logText = logState.logText
- rawLogText = logState.rawLogText
- reviewOutputText = logState.reviewOutputText
- activityLogText = logState.activityLogText
- diagnosticText = logState.diagnosticText
- cappedLogBytes = logState.cappedBytes
- lastLogMutation = mutation
- logRevision &+= 1
- }
-
- private nonisolated static func trimmedLogState(entries initialEntries: [ReviewLogEntry]) -> TrimmedLogState {
- var entries = initialEntries.map {
- $0.clampingUnownedRetainedMetadata(maxBytes: logLimitBytes)
- }
- var logState = LogState(entries: entries)
-
- while logState.cappedBytes > logLimitBytes {
- let overflowBytes = logState.cappedBytes - logLimitBytes
- guard let trimmedEntries = trimOnce(entries: entries, overflowBytes: overflowBytes) else {
- break
- }
- entries = trimmedEntries
- logState = LogState(entries: entries)
- }
-
- return .init(entries: entries, logState: logState)
- }
-
- private nonisolated static func trimOnce(
- entries: [ReviewLogEntry],
- overflowBytes: Int
- ) -> [ReviewLogEntry]? {
- if let index = entries.firstIndex(where: { $0.kind == .diagnostic }) {
- return trimWholeEntryPreferringNewest(
- entries: entries,
- at: index,
- kind: .diagnostic,
- overflowBytes: overflowBytes
- )
- }
-
- if let index = entries.firstIndex(where: { $0.kind == .rawReasoning }) {
- return trimEntry(
- entries: entries,
- at: index,
- overflowBytes: overflowBytes,
- direction: .suffix
- )
- }
-
- if let index = entries.firstIndex(where: { prefixTrimmableCappedKinds.contains($0.kind) }) {
- return trimEntry(
- entries: entries,
- at: index,
- overflowBytes: overflowBytes,
- direction: .suffix
- )
- }
-
- if let index = entries.firstIndex(where: { $0.kind == .error }) {
- return trimEntry(
- entries: entries,
- at: index,
- overflowBytes: overflowBytes,
- direction: .suffix
- )
- }
-
- return nil
- }
-
- private nonisolated static func trimWholeEntryPreferringNewest(
- entries: [ReviewLogEntry],
- at index: Int,
- kind: ReviewLogEntry.Kind,
- overflowBytes: Int
- ) -> [ReviewLogEntry] {
- let entry = entries[index]
- let hasNewerEntryOfSameKind = entries.dropFirst(index + 1).contains { $0.kind == kind }
- let hasOtherCappedEntries = entries.contains {
- $0.id != entry.id && cappedLogKinds.contains($0.kind)
- }
-
- if hasNewerEntryOfSameKind || hasOtherCappedEntries {
- var trimmedEntries = entries
- trimmedEntries.remove(at: index)
- return trimmedEntries
- }
-
- return trimEntry(
- entries: entries,
- at: index,
- overflowBytes: overflowBytes,
- direction: .prefix
- )
- }
-
- private nonisolated static func trimEntry(
- entries: [ReviewLogEntry],
- at index: Int,
- overflowBytes: Int,
- direction: TruncationDirection
- ) -> [ReviewLogEntry] {
- var trimmedEntries = entries
- let entry = trimmedEntries[index]
-
- if entry.text.utf8.count <= overflowBytes {
- trimmedEntries.remove(at: index)
- return trimmedEntries
- }
-
- let retainedBytes = max(0, entry.text.utf8.count - overflowBytes)
- let truncatedText = switch direction {
- case .prefix:
- truncateTextKeepingUTF8Prefix(entry.text, bytes: retainedBytes)
- case .suffix:
- truncateTextKeepingUTF8Suffix(entry.text, bytes: retainedBytes)
- }
-
- if truncatedText.isEmpty {
- trimmedEntries.remove(at: index)
- return trimmedEntries
- }
-
- trimmedEntries[index] = .init(
- id: entry.id,
- kind: entry.kind,
- groupID: entry.groupID,
- replacesGroup: entry.replacesGroup,
- text: truncatedText,
- metadata: entry.metadata?.truncatingRetainedText(from: entry.text, to: truncatedText),
- timestamp: entry.timestamp
- )
- return trimmedEntries
- }
-
- private nonisolated static func truncateTextKeepingUTF8Prefix(_ text: String, bytes maxBytes: Int) -> String {
- guard maxBytes > 0 else {
- return ""
- }
-
- var result = ""
- var usedBytes = 0
- for character in text {
- let characterBytes = String(character).utf8.count
- if usedBytes + characterBytes > maxBytes {
- break
- }
- result.append(character)
- usedBytes += characterBytes
- }
- return result
- }
-
- private nonisolated static func truncateTextKeepingUTF8Suffix(_ text: String, bytes maxBytes: Int) -> String {
- guard maxBytes > 0 else {
- return ""
- }
-
- var reversedCharacters: [Character] = []
- var usedBytes = 0
- for character in text.reversed() {
- let characterBytes = String(character).utf8.count
- if usedBytes + characterBytes > maxBytes {
- break
- }
- reversedCharacters.append(character)
- usedBytes += characterBytes
- }
- return String(reversedCharacters.reversed())
- }
-
- private nonisolated static func combinedText(sections: [String]) -> String {
- joinSectionsPreservingWhitespace(
- sections.filter { $0.isEmpty == false }
- )
- }
-
- private nonisolated static func mergeKey(for entry: ReviewLogEntry) -> GroupKey? {
- guard let groupID = entry.groupID,
- groupID.isEmpty == false
- else {
- return nil
- }
-
- switch entry.kind {
- case .agentMessage, .command, .commandOutput, .plan, .reasoning, .reasoningSummary, .rawReasoning, .contextCompaction:
- return GroupKey(kind: entry.kind, groupID: groupID)
- case .todoList, .toolCall, .diagnostic, .error, .progress, .event:
- return nil
- }
- }
-
- private nonisolated static func activeCommandClosingEntries(
- entries: [ReviewLogEntry],
- status: String,
- completedAt: Date
- ) -> [ReviewLogEntry] {
- var latestCommandByGroupID: [String: ReviewLogEntry] = [:]
- var orderedGroupIDs: [String] = []
-
- for entry in entries where entry.kind == .command {
- guard let groupID = entry.groupID?.nilIfEmpty else {
- continue
- }
- if latestCommandByGroupID[groupID] == nil {
- orderedGroupIDs.append(groupID)
- }
- latestCommandByGroupID[groupID] = entry
- }
-
- return orderedGroupIDs.compactMap { groupID in
- guard let entry = latestCommandByGroupID[groupID],
- entry.isActiveCommandEntry
- else {
- return nil
- }
- return entry.closingActiveCommandEntry(status: status, completedAt: completedAt)
- }
- }
-
- private nonisolated static func isVisibleInRenderedProjection(
- kind: ReviewLogEntry.Kind,
- text: String,
- visibleKinds: Set,
- includeEmptyDiagnostic: Bool
- ) -> Bool {
- guard visibleKinds.contains(kind) else {
- return false
- }
- if kind == .diagnostic {
- return includeEmptyDiagnostic || text.isEmpty == false
- }
- return text.isEmpty == false
- }
-
- private nonisolated static func joinSectionsPreservingWhitespace(_ sections: [String]) -> String {
- var iterator = sections.makeIterator()
- guard var result = iterator.next() else {
- return ""
- }
-
- while let next = iterator.next() {
- if next.isEmpty {
- result += "\n\n"
- continue
- }
- if result.hasSuffix("\n\n") {
- result += next
- continue
- }
- if result.hasSuffix("\n") || next.hasPrefix("\n") {
- result += "\n"
- } else {
- result += "\n\n"
- }
- result += next
- }
-
- return result
- }
-
- private nonisolated static let displayedLogKinds: Set = [
- .agentMessage,
- .command,
- .commandOutput,
- .plan,
- .todoList,
- .reasoning,
- .reasoningSummary,
- .rawReasoning,
- .toolCall,
- .diagnostic,
- .error,
- .progress,
- .event,
- .contextCompaction,
- ]
-
- private nonisolated static let reviewOutputKinds: Set = [
- .agentMessage,
- .plan,
- .reasoningSummary,
- .reasoning,
- .rawReasoning,
- ]
-
- private nonisolated static let activityKinds: Set = [
- .command,
- .commandOutput,
- .toolCall,
- .progress,
- .event,
- .contextCompaction,
- ]
-
- private nonisolated static let cappedLogKinds: Set = [
- .agentMessage,
- .commandOutput,
- .toolCall,
- .plan,
- .todoList,
- .reasoningSummary,
- .rawReasoning,
- .diagnostic,
- .error,
- .progress,
- .event,
- ]
-
- private nonisolated static let prefixTrimmableCappedKinds = cappedLogKinds.subtracting([.rawReasoning, .diagnostic, .error])
-
- private nonisolated static let logLimitBytes = 256 * 1024
-}
-
-private extension ReviewLogEntry {
- func clampingUnownedRetainedMetadata(maxBytes: Int) -> ReviewLogEntry {
- guard let metadata else {
- return self
- }
- let clampedMetadata = metadata.clampingUnownedRetainedText(entryText: text, maxBytes: maxBytes)
- guard clampedMetadata != metadata else {
- return self
- }
- return ReviewLogEntry(
- id: id,
- kind: kind,
- groupID: groupID,
- replacesGroup: replacesGroup,
- text: text,
- metadata: clampedMetadata,
- timestamp: timestamp
- )
- }
-
- var isActiveCommandEntry: Bool {
- guard kind == .command else {
- return false
- }
- let status = metadata?.commandStatus ?? metadata?.status
- return status == "inProgress" || status == "running" || status == "started"
- }
-
- func closingActiveCommandEntry(status: String, completedAt: Date) -> ReviewLogEntry {
- let startedAt = metadata?.startedAt
- let durationMs = Self.durationMs(startedAt: startedAt, completedAt: completedAt)
- ?? metadata?.durationMs
- return ReviewLogEntry(
- kind: kind,
- groupID: groupID,
- replacesGroup: true,
- text: text,
- metadata: .init(
- sourceType: metadata?.sourceType ?? "commandExecution",
- title: metadata?.title,
- status: status,
- detail: metadata?.detail,
- itemID: metadata?.itemID ?? groupID,
- command: metadata?.command ?? Self.commandText(from: text),
- cwd: metadata?.cwd,
- exitCode: metadata?.exitCode,
- startedAt: startedAt,
- completedAt: completedAt,
- durationMs: durationMs,
- commandActions: metadata?.commandActions,
- commandStatus: status,
- namespace: metadata?.namespace,
- server: metadata?.server,
- tool: metadata?.tool,
- query: metadata?.query,
- path: metadata?.path,
- resultText: metadata?.resultText,
- errorText: metadata?.errorText
- ),
- timestamp: completedAt
- )
- }
-
- private static func durationMs(startedAt: Date?, completedAt: Date) -> Int? {
- guard let startedAt else {
- return nil
- }
- let milliseconds = completedAt.timeIntervalSince(startedAt) * 1000
- guard milliseconds.isFinite else {
- return nil
- }
- return max(0, Int(milliseconds.rounded()))
- }
-
- private static func commandText(from text: String) -> String? {
- let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
- guard trimmed.hasPrefix("$ ") else {
- return trimmed.nilIfEmpty
- }
- return String(trimmed.dropFirst(2)).nilIfEmpty
- }
-}
-
-private extension ReviewLogEntry.Metadata {
- func clampingUnownedRetainedText(entryText: String, maxBytes: Int) -> Self {
- Self(
- sourceType: sourceType,
- title: title,
- status: status,
- detail: detail,
- itemID: itemID,
- command: command,
- cwd: cwd,
- exitCode: exitCode,
- startedAt: startedAt,
- completedAt: completedAt,
- durationMs: durationMs,
- commandActions: commandActions,
- commandStatus: commandStatus,
- namespace: namespace,
- server: server,
- tool: tool,
- query: query,
- path: path,
- resultText: Self.clampedUnownedRetainedText(resultText, entryText: entryText, maxBytes: maxBytes),
- errorText: Self.clampedUnownedRetainedText(errorText, entryText: entryText, maxBytes: maxBytes)
- )
- }
-
- func truncatingRetainedText(from originalText: String, to truncatedText: String) -> Self {
- Self(
- sourceType: sourceType,
- title: title,
- status: status,
- detail: detail,
- itemID: itemID,
- command: command,
- cwd: cwd,
- exitCode: exitCode,
- startedAt: startedAt,
- completedAt: completedAt,
- durationMs: durationMs,
- commandActions: commandActions,
- commandStatus: commandStatus,
- namespace: namespace,
- server: server,
- tool: tool,
- query: query,
- path: path,
- resultText: resultText == originalText ? truncatedText : resultText,
- errorText: errorText == originalText ? truncatedText : errorText
- )
- }
-
- private static func clampedUnownedRetainedText(_ text: String?, entryText: String, maxBytes: Int) -> String? {
- guard let text else {
- return nil
- }
- guard text != entryText,
- text.utf8.count > maxBytes
- else {
- return text
- }
- return nil
- }
-}
-
-private extension ReviewDomainEvent {
- var appliesFromDirectTimelineSource: Bool {
- switch self {
- case .itemStarted, .itemUpdated, .itemCompleted, .textDelta:
- true
- case .runStarted, .reviewCompleted, .reviewFailed, .reviewCancelled:
- false
- }
- }
-
- var directTimelineTextItemID: ReviewTimelineItem.ID? {
- switch self {
- case .itemStarted(let seed),
- .itemUpdated(let seed),
- .itemCompleted(let seed):
- seed.hasTrimmableTimelineText ? seed.id : nil
- case .textDelta(let itemID, _, _, _, _):
- itemID
- case .runStarted, .reviewCompleted, .reviewFailed, .reviewCancelled:
- nil
- }
- }
-}
-
-private extension ReviewTimelineItemSeed {
- var hasTrimmableTimelineText: Bool {
- switch content {
- case .fileChange(let fileChange):
- fileChange.output.isEmpty == false
- && kind.rawValue != "item/fileChange/patchUpdated"
- default:
- content.hasTrimmableTimelineText
- }
- }
-}
-
-private extension ReviewTimelineItem.Content {
- var hasTrimmableTimelineText: Bool {
- switch self {
- case .command(let command):
- command.output.isEmpty == false
- case .diagnostic(let diagnostic):
- diagnostic.message.isEmpty == false
- case .message,
- .plan,
- .reasoning:
- true
- case .search(let search):
- search.result?.isEmpty == false
- case .toolCall(let toolCall):
- toolCall.progress?.isEmpty == false
- || toolCall.result?.isEmpty == false
- || toolCall.error?.isEmpty == false
- case .fileChange:
- false
- case .approval,
- .contextCompaction,
- .unknown:
- false
- }
- }
-}
diff --git a/Sources/CodexReview/Model/CodexReviewWorkspace.swift b/Sources/CodexReview/Model/CodexReviewWorkspace.swift
deleted file mode 100644
index 8aaf24d6..00000000
--- a/Sources/CodexReview/Model/CodexReviewWorkspace.swift
+++ /dev/null
@@ -1,27 +0,0 @@
-import Foundation
-import Observation
-
-@MainActor
-@Observable
-public final class CodexReviewWorkspace: Hashable {
- public nonisolated let cwd: String
- public package(set) var sortOrder: Double
-
- public nonisolated var displayTitle: String {
- let title = URL(fileURLWithPath: cwd).lastPathComponent
- return title.isEmpty ? cwd : title
- }
-
- public init(cwd: String, sortOrder: Double = 0) {
- self.cwd = cwd
- self.sortOrder = sortOrder
- }
-
- public nonisolated static func == (lhs: CodexReviewWorkspace, rhs: CodexReviewWorkspace) -> Bool {
- lhs.cwd == rhs.cwd
- }
-
- public nonisolated func hash(into hasher: inout Hasher) {
- hasher.combine(cwd)
- }
-}
diff --git a/Sources/CodexReview/Model/ReviewJobCore.swift b/Sources/CodexReview/Model/ReviewJobCore.swift
deleted file mode 100644
index 0cf87646..00000000
--- a/Sources/CodexReview/Model/ReviewJobCore.swift
+++ /dev/null
@@ -1,105 +0,0 @@
-import Foundation
-
-public struct ReviewJobCore: Codable, Sendable, Hashable {
- public struct Run: Codable, Sendable, Hashable {
- public internal(set) var reviewThreadID: String?
- public internal(set) var threadID: String?
- public internal(set) var turnID: String?
- public internal(set) var model: String?
-
- public init(
- reviewThreadID: String? = nil,
- threadID: String? = nil,
- turnID: String? = nil,
- model: String? = nil
- ) {
- self.reviewThreadID = reviewThreadID
- self.threadID = threadID
- self.turnID = turnID
- self.model = model
- }
- }
-
- public struct Lifecycle: Codable, Sendable, Hashable {
- public internal(set) var status: ReviewJobState
- public internal(set) var exitCode: Int?
- public internal(set) var startedAt: Date?
- public internal(set) var endedAt: Date?
- public internal(set) var cancellation: ReviewCancellation?
- public internal(set) var errorMessage: String?
-
- public init(
- status: ReviewJobState,
- exitCode: Int? = nil,
- startedAt: Date? = nil,
- endedAt: Date? = nil,
- cancellation: ReviewCancellation? = nil,
- errorMessage: String? = nil
- ) {
- self.status = status
- self.exitCode = exitCode
- self.startedAt = startedAt
- self.endedAt = endedAt
- self.cancellation = cancellation
- self.errorMessage = errorMessage
- }
- }
-
- public struct Output: Codable, Sendable, Hashable {
- public internal(set) var summary: String
- public internal(set) var hasFinalReview: Bool
- public internal(set) var lastAgentMessage: String?
- public internal(set) var reviewResult: ParsedReviewResult?
-
- public init(
- summary: String,
- hasFinalReview: Bool = false,
- lastAgentMessage: String? = nil,
- reviewResult: ParsedReviewResult? = nil
- ) {
- self.summary = summary
- self.hasFinalReview = hasFinalReview
- self.lastAgentMessage = lastAgentMessage
- self.reviewResult = reviewResult
- }
- }
-
- public internal(set) var run: Run
- public internal(set) var lifecycle: Lifecycle
- public internal(set) var output: Output
-
- public init(
- run: Run = .init(),
- lifecycle: Lifecycle,
- output: Output
- ) {
- self.run = run
- self.lifecycle = lifecycle
- self.output = output
- }
-
- public var isTerminal: Bool {
- lifecycle.status.isTerminal
- }
-
- public var reviewText: String {
- if lifecycle.status == .cancelled {
- if output.hasFinalReview,
- let lastAgentMessage = output.lastAgentMessage?.nilIfEmpty
- {
- return lastAgentMessage
- }
- if let errorMessage = lifecycle.errorMessage?.nilIfEmpty {
- return errorMessage
- }
- return output.summary
- }
- if let lastAgentMessage = output.lastAgentMessage?.nilIfEmpty {
- return lastAgentMessage
- }
- if let errorMessage = lifecycle.errorMessage?.nilIfEmpty {
- return errorMessage
- }
- return output.summary
- }
-}
diff --git a/Sources/CodexReview/Model/ReviewLogEntry.swift b/Sources/CodexReview/Model/ReviewLogEntry.swift
deleted file mode 100644
index aed76a6e..00000000
--- a/Sources/CodexReview/Model/ReviewLogEntry.swift
+++ /dev/null
@@ -1,143 +0,0 @@
-import Foundation
-
-public struct ReviewLogEntry: Codable, Identifiable, Sendable, Hashable {
- public struct Metadata: Codable, Sendable, Hashable {
- public struct CommandAction: Codable, Sendable, Hashable {
- public enum Kind: String, Codable, Sendable, Hashable {
- case read
- case listFiles
- case search
- case unknown
- }
-
- public let kind: Kind
- public let command: String?
- public let name: String?
- public let path: String?
- public let 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 let sourceType: String
- public let title: String?
- public let status: String?
- public let detail: String?
- public let itemID: String?
- public let command: String?
- public let cwd: String?
- public let exitCode: Int?
- public let startedAt: Date?
- public let completedAt: Date?
- public let durationMs: Int?
- public let commandActions: [CommandAction]?
- public let commandStatus: String?
- public let namespace: String?
- public let server: String?
- public let tool: String?
- public let query: String?
- public let path: String?
- public let resultText: String?
- public let errorText: String?
-
- public init(
- sourceType: String,
- title: String? = nil,
- status: String? = nil,
- detail: String? = nil,
- itemID: String? = nil,
- command: String? = nil,
- cwd: String? = nil,
- exitCode: Int? = nil,
- startedAt: Date? = nil,
- completedAt: Date? = nil,
- durationMs: Int? = nil,
- commandActions: [CommandAction]? = nil,
- commandStatus: String? = nil,
- namespace: String? = nil,
- server: String? = nil,
- tool: String? = nil,
- query: String? = nil,
- path: String? = nil,
- resultText: String? = nil,
- errorText: String? = nil
- ) {
- self.sourceType = sourceType
- self.title = title
- self.status = status
- self.detail = detail
- self.itemID = itemID
- self.command = command
- self.cwd = cwd
- self.exitCode = exitCode
- self.startedAt = startedAt
- self.completedAt = completedAt
- self.durationMs = durationMs
- self.commandActions = commandActions
- self.commandStatus = commandStatus
- self.namespace = namespace
- self.server = server
- self.tool = tool
- self.query = query
- self.path = path
- self.resultText = resultText
- self.errorText = errorText
- }
- }
-
- public enum Kind: String, Codable, Sendable, Hashable {
- case agentMessage
- case command
- case commandOutput
- case plan
- case todoList
- case reasoning
- case reasoningSummary
- case rawReasoning
- case toolCall
- case diagnostic
- case error
- case progress
- case event
- case contextCompaction
- }
-
- public let id: UUID
- public let kind: Kind
- public let groupID: String?
- public let replacesGroup: Bool
- public let text: String
- public let metadata: Metadata?
- public let timestamp: Date
-
- public init(
- id: UUID = UUID(),
- kind: Kind,
- groupID: String? = nil,
- replacesGroup: Bool = false,
- text: String,
- metadata: Metadata? = nil,
- timestamp: Date = Date()
- ) {
- self.id = id
- self.kind = kind
- self.groupID = groupID
- self.replacesGroup = replacesGroup
- self.text = text
- self.metadata = metadata
- self.timestamp = timestamp
- }
-
-}
diff --git a/Sources/CodexReview/Model/ReviewLogEntryTimelineProjection.swift b/Sources/CodexReview/Model/ReviewLogEntryTimelineProjection.swift
deleted file mode 100644
index caabcf3f..00000000
--- a/Sources/CodexReview/Model/ReviewLogEntryTimelineProjection.swift
+++ /dev/null
@@ -1,542 +0,0 @@
-import Foundation
-import CodexReviewDomain
-
-@MainActor
-extension CodexReviewJob {
- package func rebuildTimelineFromLogEntries(keepingTerminal: Bool = true) {
- guard usesDirectTimelineEvents == false else {
- return
- }
- timeline.reset(keepingTerminal: keepingTerminal && core.lifecycle.status.isTerminal)
- for entry in logEntries {
- applyTimelineEntry(entry)
- }
- syncTimelineTerminalStateFromCore()
- }
-
- package func applyTimelineEntry(_ entry: ReviewLogEntry) {
- let itemID = entry.timelineItemID
- let existing = timeline.item(for: itemID)
- let kind = entry.timelineItemKind
- let family = entry.timelineItemFamily
- let content = entry.timelineContent(existing: existing)
-
- if entry.shouldApplyAsTimelineDelta {
- timeline.apply(.textDelta(
- itemID: itemID,
- kind: kind,
- family: family,
- content: content.emptyTextContent,
- delta: entry.text
- ), at: entry.timestamp)
- return
- }
-
- let seed = ReviewTimelineItemSeed(
- id: itemID,
- kind: kind,
- family: family,
- phase: entry.timelinePhase,
- content: content,
- startedAt: entry.metadata?.startedAt,
- completedAt: entry.metadata?.completedAt,
- durationMs: entry.metadata?.durationMs
- )
-
- if seed.phase.isTerminal {
- timeline.apply(.itemCompleted(seed), at: entry.timestamp)
- } else if existing == nil {
- timeline.apply(.itemStarted(seed), at: entry.timestamp)
- } else {
- timeline.apply(.itemUpdated(seed), at: entry.timestamp)
- }
- }
-
- package func trimTimelineTextContentToLogEntries() {
- guard directTimelineTextItemIDs.isEmpty == false
- || legacyProjectedTimelineTextItemIDs.isEmpty == false else {
- return
- }
- var textByItemID: [ReviewTimelineItem.ID: String] = [:]
- for entry in logEntries {
- guard let retainedTimelineText = entry.retainedTimelineText else {
- continue
- }
- for itemID in directTimelineTextCandidateIDs(for: entry) {
- if entry.shouldAppendRetainedTimelineText {
- textByItemID[itemID, default: ""] += retainedTimelineText
- } else {
- textByItemID[itemID] = retainedTimelineText
- }
- }
- }
- for itemID in directTimelineTextItemIDs {
- let text: String
- if let retainedText = textByItemID[itemID] {
- text = retainedText
- } else if directTimelineTextItemIDsWithCompatibilityLog.contains(itemID) {
- text = ""
- } else {
- continue
- }
- guard let item = timeline.item(for: itemID),
- let trimmedContent = item.content.replacingTimelineText(text)
- else {
- continue
- }
- timeline.updateItemContent(trimmedContent, for: itemID)
- }
- for itemID in legacyProjectedTimelineTextItemIDs {
- guard let item = timeline.item(for: itemID),
- let trimmedContent = item.content.replacingTimelineText(textByItemID[itemID] ?? "")
- else {
- continue
- }
- timeline.updateItemContent(trimmedContent, for: itemID)
- }
- }
-
- package func syncTimelineTerminalStateFromCore() {
- guard core.lifecycle.status.isTerminal else {
- return
- }
- let timestamp = core.lifecycle.endedAt ?? logEntries.last?.timestamp ?? Date()
- switch core.lifecycle.status {
- case .succeeded:
- timeline.apply(
- .reviewCompleted(
- summary: core.output.summary,
- result: core.output.hasFinalReview ? core.output.lastAgentMessage?.nilIfEmpty : nil
- ),
- at: timestamp
- )
- case .failed:
- timeline.apply(.reviewFailed(core.lifecycle.errorMessage?.nilIfEmpty ?? core.output.summary), at: timestamp)
- case .cancelled:
- timeline.apply(
- .reviewCancelled(
- core.lifecycle.cancellation?.message.nilIfEmpty
- ?? core.lifecycle.errorMessage?.nilIfEmpty
- ?? core.output.summary
- ),
- at: timestamp
- )
- case .queued, .running:
- break
- }
- }
-
- private func directTimelineTextCandidateIDs(for entry: ReviewLogEntry) -> [ReviewTimelineItem.ID] {
- var ids = entry.directTimelineTextCandidateIDs
- if let compatibilityItemIDs = directTimelineTextCompatibilityItemIDsByLogEntryID[entry.id] {
- ids.append(contentsOf: compatibilityItemIDs)
- }
- var seen: Set = []
- return ids.filter { seen.insert($0).inserted }
- }
-}
-
-package extension ReviewLogEntry {
- var retainedTimelineText: String? {
- guard canProvideDirectTimelineText else {
- return nil
- }
- switch timelineItemFamily {
- case .fileChange:
- return kind == .commandOutput ? text : nil
- case .search:
- if metadata?.resultText == text {
- return text
- }
- return metadata?.sourceType == "webSearch" ? nil : text
- case .tool:
- if metadata?.resultText == text || metadata?.errorText == text {
- return text
- }
- return metadata?.resultText == nil && metadata?.errorText == nil ? text : nil
- case .approval,
- .command,
- .contextCompaction,
- .diagnostic,
- .lifecycle,
- .message,
- .plan,
- .reasoning,
- .unknown:
- return text
- }
- }
-
- var canProvideDirectTimelineText: Bool {
- switch kind {
- case .agentMessage,
- .commandOutput,
- .diagnostic,
- .error,
- .event,
- .plan,
- .progress,
- .rawReasoning,
- .reasoning,
- .reasoningSummary,
- .todoList,
- .toolCall:
- true
- case .command,
- .contextCompaction:
- false
- }
- }
-
- var directTimelineTextCandidateIDs: [ReviewTimelineItem.ID] {
- var ids: [ReviewTimelineItem.ID] = []
- if let rawID = metadata?.itemID?.nilIfEmpty ?? groupID?.nilIfEmpty {
- ids.append(.init(rawValue: rawID))
- if kind == .rawReasoning,
- let directRawReasoningID = rawID.rawReasoningDirectTimelineItemID {
- ids.append(.init(rawValue: directRawReasoningID))
- }
- if kind == .todoList {
- ids.append(.init(rawValue: "\(rawID):turn/plan/updated"))
- }
- if isMCPToolProgressCompatibilityLog {
- ids.append(.init(rawValue: "\(rawID):progress"))
- }
- }
- ids.append(timelineItemID)
- var seen: Set = []
- return ids.filter { seen.insert($0).inserted }
- }
-
- var isMCPToolProgressCompatibilityLog: Bool {
- kind == .toolCall
- && metadata?.sourceType == "mcpToolCall"
- && metadata?.title?.trimmingCharacters(in: .whitespacesAndNewlines) == "Tool progress"
- }
-
- var shouldAppendRetainedTimelineText: Bool {
- shouldApplyAsTimelineDelta || (kind == .commandOutput && replacesGroup == false)
- }
-
- var timelineItemID: ReviewTimelineItem.ID {
- let rawID: String = if shouldUseSemanticTimelineID {
- semanticTimelineItemID
- } else {
- "\(kind.rawValue):\(id.uuidString)"
- }
- return .init(rawValue: rawID)
- }
-
- var semanticTimelineItemID: String {
- let baseID = metadata?.itemID?.nilIfEmpty
- ?? groupID?.nilIfEmpty
- ?? "\(kind.rawValue):\(id.uuidString)"
- switch kind {
- case .command, .commandOutput:
- return baseID
- case .agentMessage, .plan, .todoList, .reasoning, .reasoningSummary, .rawReasoning, .toolCall, .diagnostic, .error, .progress, .event, .contextCompaction:
- return "\(kind.rawValue):\(baseID)"
- }
- }
-
- var timelineItemKind: ReviewItemKind {
- if let sourceType = metadata?.sourceType.nilIfEmpty {
- return .init(rawValue: sourceType)
- }
-
- switch kind {
- case .agentMessage:
- return .agentMessage
- case .command, .commandOutput:
- return .commandExecution
- case .plan, .todoList, .reasoning, .reasoningSummary, .rawReasoning:
- return .init(rawValue: kind.rawValue)
- case .toolCall:
- return .dynamicToolCall
- case .contextCompaction:
- return .contextCompaction
- case .diagnostic, .error, .progress, .event:
- return .init(rawValue: kind.rawValue)
- }
- }
-
- var timelineItemFamily: ReviewItemFamily {
- switch metadata?.sourceType {
- case "commandExecution":
- return .command
- case "fileChange":
- return .fileChange
- case "mcpToolCall", "dynamicToolCall", "collabAgentToolCall":
- return .tool
- case "webSearch":
- return .search
- case "contextCompaction":
- return .contextCompaction
- default:
- break
- }
-
- switch kind {
- case .agentMessage:
- return .message
- case .command, .commandOutput:
- return .command
- case .plan, .todoList:
- return .plan
- case .reasoning, .reasoningSummary, .rawReasoning:
- return .reasoning
- case .toolCall:
- return .tool
- case .contextCompaction:
- return .contextCompaction
- case .diagnostic, .error, .progress, .event:
- return .diagnostic
- }
- }
-
- var timelinePhase: ReviewItemPhase {
- if kind == .error {
- return .failed
- }
- if metadata == nil, groupID?.nilIfEmpty == nil {
- return .completed
- }
- let rawStatus = metadata?.commandStatus ?? metadata?.status
- if let rawStatus {
- return ReviewItemPhase.normalized(rawStatus)
- }
- if replacesGroup {
- switch kind {
- case .agentMessage, .plan, .todoList, .reasoning, .reasoningSummary, .rawReasoning:
- return .completed
- default:
- break
- }
- }
- switch kind {
- case .diagnostic, .progress, .event:
- return .completed
- default:
- return .running
- }
- }
-
- var shouldApplyAsTimelineDelta: Bool {
- guard replacesGroup == false else {
- return false
- }
- guard groupID?.nilIfEmpty != nil || metadata?.itemID?.nilIfEmpty != nil else {
- return false
- }
- switch kind {
- case .agentMessage, .plan, .todoList, .reasoning, .reasoningSummary, .rawReasoning:
- return true
- case .commandOutput:
- return metadata == nil
- case .command, .toolCall, .diagnostic, .error, .progress, .event, .contextCompaction:
- return false
- }
- }
-
- var shouldUseSemanticTimelineID: Bool {
- if replacesGroup {
- return true
- }
- switch kind {
- case .agentMessage, .command, .commandOutput, .plan, .todoList, .reasoning, .reasoningSummary, .rawReasoning, .contextCompaction:
- return groupID?.nilIfEmpty != nil || metadata?.itemID?.nilIfEmpty != nil
- case .toolCall, .diagnostic, .error, .progress, .event:
- return false
- }
- }
-
- @MainActor
- func timelineContent(existing: ReviewTimelineItem?) -> ReviewTimelineItem.Content {
- switch timelineItemFamily {
- case .command:
- return .command(commandContent(existing: existing))
- case .fileChange:
- return .fileChange(fileChangeContent(existing: existing))
- case .message:
- return .message(.init(text: text))
- case .plan:
- return .plan(.init(markdown: text))
- case .reasoning:
- return .reasoning(.init(
- text: text,
- style: kind == .rawReasoning ? .raw : .summary
- ))
- case .tool:
- let errorText = metadata?.errorText
- return .toolCall(.init(
- namespace: metadata?.namespace,
- server: metadata?.server,
- tool: metadata?.tool,
- arguments: metadata?.detail,
- result: metadata?.resultText ?? (errorText == nil ? text.nilIfEmpty : nil),
- error: errorText
- ))
- case .search:
- return .search(.init(
- query: metadata?.query ?? text,
- result: metadata?.resultText
- ))
- case .contextCompaction:
- return .contextCompaction(.init(title: text))
- case .approval:
- return .approval(.init(title: metadata?.title ?? text, detail: metadata?.detail))
- case .diagnostic, .lifecycle:
- return .diagnostic(.init(message: text))
- case .unknown:
- return .unknown(.init(title: metadata?.title ?? kind.rawValue, detail: text.nilIfEmpty))
- }
- }
-
- @MainActor
- private func commandContent(existing: ReviewTimelineItem?) -> ReviewTimelineItem.Command {
- let existingCommand: ReviewTimelineItem.Command? = if case .command(let command) = existing?.content {
- command
- } else {
- nil
- }
- let commandText: String
- if kind == .commandOutput {
- commandText = metadata?.command?.nilIfEmpty
- ?? existingCommand?.command.nilIfEmpty
- ?? ""
- } else {
- commandText = metadata?.command?.nilIfEmpty
- ?? existingCommand?.command.nilIfEmpty
- ?? Self.commandText(from: text)
- ?? metadata?.title?.nilIfEmpty
- ?? "Command"
- }
- let output: String
- if kind == .commandOutput {
- output = replacesGroup ? text : (existingCommand?.output ?? "") + text
- } else {
- output = existingCommand?.output ?? ""
- }
- return .init(
- command: commandText,
- cwd: metadata?.cwd ?? existingCommand?.cwd,
- output: output,
- exitCode: metadata?.exitCode ?? existingCommand?.exitCode,
- actions: metadata?.commandActions?.map(ReviewTimelineItem.CommandAction.init) ?? existingCommand?.actions ?? []
- )
- }
-
- @MainActor
- private func fileChangeContent(existing: ReviewTimelineItem?) -> ReviewTimelineItem.FileChange {
- let existingFileChange: ReviewTimelineItem.FileChange? = if case .fileChange(let fileChange) = existing?.content {
- fileChange
- } else {
- nil
- }
- let title = metadata?.title?.nilIfEmpty
- ?? metadata?.path?.nilIfEmpty
- ?? existingFileChange?.title.nilIfEmpty
- ?? "File changes"
- let output: String
- if kind == .commandOutput {
- output = replacesGroup ? text : (existingFileChange?.output ?? "") + text
- } else {
- output = existingFileChange?.output ?? text
- }
- return .init(title: title, output: output)
- }
-
- private static func commandText(from text: String) -> String? {
- let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
- guard trimmed.hasPrefix("$ ") else {
- return trimmed.nilIfEmpty
- }
- return String(trimmed.dropFirst(2)).nilIfEmpty
- }
-}
-
-private extension ReviewTimelineItem.CommandAction {
- init(_ action: ReviewLogEntry.Metadata.CommandAction) {
- self.init(
- kind: .init(rawValue: action.kind.rawValue),
- command: action.command,
- name: action.name,
- path: action.path,
- query: action.query
- )
- }
-}
-
-private extension ReviewTimelineItem.Content {
- func replacingTimelineText(_ text: String) -> Self? {
- switch self {
- case .command(var command):
- command.output = text
- return .command(command)
- case .diagnostic(var diagnostic):
- diagnostic.message = text
- return .diagnostic(diagnostic)
- case .fileChange(var fileChange):
- fileChange.output = text
- return .fileChange(fileChange)
- case .message:
- return .message(.init(text: text))
- case .plan:
- return .plan(.init(markdown: text))
- case .reasoning(let reasoning):
- return .reasoning(.init(text: text, style: reasoning.style))
- case .search(var search):
- search.result = text.nilIfEmpty
- return .search(search)
- case .toolCall(var toolCall):
- if toolCall.progress != nil {
- toolCall.progress = text
- } else if toolCall.error != nil {
- toolCall.error = text
- } else {
- toolCall.result = text
- }
- return .toolCall(toolCall)
- case .approval, .contextCompaction, .unknown:
- return nil
- }
- }
-}
-
-private extension String {
- var rawReasoningDirectTimelineItemID: String? {
- guard let separator = lastIndex(of: ":") else {
- return nil
- }
- let contentIndex = self[index(after: separator)...]
- guard contentIndex.isEmpty == false,
- contentIndex.allSatisfy(\.isNumber)
- else {
- return nil
- }
- return "\(self[.. [ReviewLogEntry] {
- switch item.content {
- case .message(let message):
- return [entry(item: item, kind: .agentMessage, text: message.text)]
- case .command(let command):
- var entries: [ReviewLogEntry] = []
- let commandLine = commandLineText(for: command)
- if let commandLine {
- entries.append(entry(
- item: item,
- kind: .command,
- text: "$ \(commandLine)",
- metadata: commandMetadata(item: item, command: command)
- ))
- }
- if command.output.isEmpty == false {
- entries.append(entry(
- item: item,
- kind: .commandOutput,
- text: command.output,
- metadata: commandMetadata(item: item, command: command)
- ))
- }
- return entries
- case .fileChange(let fileChange):
- return [entry(
- item: item,
- kind: .commandOutput,
- text: fileChange.output.isEmpty ? fileChange.title : fileChange.output,
- metadata: .init(
- sourceType: "fileChange",
- title: fileChange.title,
- status: item.phase.rawValue,
- itemID: item.id.rawValue
- )
- )]
- case .plan(let plan):
- return [entry(item: item, kind: legacyKind(for: item), text: plan.markdown)]
- case .reasoning(let reasoning):
- return [entry(
- item: item,
- kind: legacyKind(for: item, fallback: reasoning.style == .raw ? .rawReasoning : .reasoningSummary),
- text: reasoning.text
- )]
- case .toolCall(let toolCall):
- let label = [toolCall.namespace, toolCall.server, toolCall.tool]
- .compactMap { $0?.nilIfEmpty }
- .joined(separator: ".")
- let text = toolCall.error
- ?? toolCall.result
- ?? toolCall.progress
- ?? label.nilIfEmpty
- ?? "Tool call"
- return [entry(
- item: item,
- kind: .toolCall,
- text: text,
- metadata: .init(
- sourceType: item.kind.rawValue,
- title: label.nilIfEmpty,
- status: item.phase.rawValue,
- detail: toolCall.arguments,
- itemID: item.id.rawValue,
- namespace: toolCall.namespace,
- server: toolCall.server,
- tool: toolCall.tool,
- resultText: toolCall.result,
- errorText: toolCall.error
- )
- )]
- case .search(let search):
- return [entry(
- item: item,
- kind: .toolCall,
- text: search.result ?? "Web search: \(search.query)",
- metadata: .init(
- sourceType: "webSearch",
- title: "Web search",
- status: item.phase.rawValue,
- itemID: item.id.rawValue,
- query: search.query,
- resultText: search.result
- )
- )]
- case .contextCompaction(let contextCompaction):
- return [entry(item: item, kind: .contextCompaction, text: contextCompaction.title)]
- case .diagnostic(let diagnostic):
- return [entry(item: item, kind: legacyKind(for: item), text: diagnostic.message)]
- case .approval(let approval):
- return [entry(
- item: item,
- kind: .event,
- text: [approval.title, approval.detail].compactMap { $0 }.joined(separator: "\n")
- )]
- case .unknown(let unknown):
- return [entry(
- item: item,
- kind: legacyKind(for: item),
- text: [unknown.title, unknown.detail].compactMap { $0 }.joined(separator: "\n")
- )]
- }
- }
-
- @MainActor
- private static func commandLineText(for command: ReviewTimelineItem.Command) -> String? {
- guard let commandLine = command.command.nilIfEmpty,
- isGenericCommandTitle(commandLine) == false
- else {
- return nil
- }
- return commandLine
- }
-
- @MainActor
- private static func isGenericCommandTitle(_ title: String) -> Bool {
- switch title.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
- case "command", "command output":
- return true
- default:
- return false
- }
- }
-
- @MainActor
- private static func entry(
- item: ReviewTimelineItem,
- kind: ReviewLogEntry.Kind,
- text: String,
- metadata: ReviewLogEntry.Metadata? = nil
- ) -> ReviewLogEntry {
- ReviewLogEntry(
- id: stableLogEntryID(item: item, kind: kind),
- kind: kind,
- groupID: legacyGroupID(for: item, kind: kind),
- replacesGroup: false,
- text: text,
- metadata: metadata,
- timestamp: item.updatedAt
- )
- }
-
- @MainActor
- private static func stableLogEntryID(
- item: ReviewTimelineItem,
- kind: ReviewLogEntry.Kind
- ) -> UUID {
- deterministicUUID(for: "timeline-log:\(item.id.rawValue):\(kind.rawValue)")
- }
-
- private static func deterministicUUID(for key: String) -> UUID {
- var first = UInt64(0xcbf29ce484222325)
- var second = UInt64(0x84222325cbf29ce4)
-
- for byte in key.utf8 {
- first ^= UInt64(byte)
- first = first &* 0x100000001b3
-
- second ^= UInt64(byte) &+ 0x9e3779b97f4a7c15
- second = second &* 0x100000001b3
- second = (second << 13) | (second >> 51)
- }
-
- var uuid: uuid_t = (
- UInt8((first >> 56) & 0xff),
- UInt8((first >> 48) & 0xff),
- UInt8((first >> 40) & 0xff),
- UInt8((first >> 32) & 0xff),
- UInt8((first >> 24) & 0xff),
- UInt8((first >> 16) & 0xff),
- UInt8((first >> 8) & 0xff),
- UInt8(first & 0xff),
- UInt8((second >> 56) & 0xff),
- UInt8((second >> 48) & 0xff),
- UInt8((second >> 40) & 0xff),
- UInt8((second >> 32) & 0xff),
- UInt8((second >> 24) & 0xff),
- UInt8((second >> 16) & 0xff),
- UInt8((second >> 8) & 0xff),
- UInt8(second & 0xff)
- )
- uuid.6 = (uuid.6 & UInt8(0x0f)) | UInt8(0x50)
- uuid.8 = (uuid.8 & UInt8(0x3f)) | UInt8(0x80)
- return UUID(uuid: uuid)
- }
-
- @MainActor
- private static func legacyKind(
- for item: ReviewTimelineItem,
- fallback: ReviewLogEntry.Kind = .event
- ) -> ReviewLogEntry.Kind {
- ReviewLogEntry.Kind(rawValue: item.kind.rawValue) ?? fallback
- }
-
- @MainActor
- private static func legacyGroupID(
- for item: ReviewTimelineItem,
- kind: ReviewLogEntry.Kind
- ) -> String {
- let rawID = item.id.rawValue
- let prefix = "\(kind.rawValue):"
- guard rawID.hasPrefix(prefix) else {
- return rawID
- }
- return String(rawID.dropFirst(prefix.count))
- }
-
- @MainActor
- private static func commandMetadata(
- item: ReviewTimelineItem,
- command: ReviewTimelineItem.Command
- ) -> ReviewLogEntry.Metadata {
- let commandActions = command.actions.map(ReviewLogEntry.Metadata.CommandAction.init)
- return .init(
- sourceType: "commandExecution",
- status: item.phase.rawValue,
- itemID: item.id.rawValue,
- command: commandLineText(for: command),
- cwd: command.cwd,
- exitCode: command.exitCode,
- startedAt: item.startedAt,
- completedAt: item.completedAt,
- durationMs: item.durationMs,
- commandActions: commandActions.isEmpty ? nil : commandActions,
- commandStatus: item.phase.rawValue
- )
- }
-}
-
-private extension ReviewLogEntry.Metadata.CommandAction {
- init(_ action: ReviewTimelineItem.CommandAction) {
- self.init(
- kind: .init(rawValue: action.kind.rawValue) ?? .unknown,
- command: action.command,
- name: action.name,
- path: action.path,
- query: action.query
- )
- }
-}
diff --git a/Sources/CodexReview/ReviewAPI/ReviewResults.swift b/Sources/CodexReview/ReviewAPI/ReviewResults.swift
deleted file mode 100644
index d7c5c076..00000000
--- a/Sources/CodexReview/ReviewAPI/ReviewResults.swift
+++ /dev/null
@@ -1,231 +0,0 @@
-import Foundation
-
-package extension CodexReviewAPI.Read {
-struct Result: Codable, Sendable, Hashable {
- package var jobID: String
- package var core: ReviewJobCore
- package var elapsedSeconds: Int?
- package var cancellable: Bool
- package var logs: [ReviewLogEntry]
- package var logsPage: CodexReviewAPI.Log.Page
- package var rawLogText: String
-
- package init(
- jobID: String,
- core: ReviewJobCore,
- elapsedSeconds: Int? = nil,
- cancellable: Bool,
- logs: [ReviewLogEntry],
- logsPage: CodexReviewAPI.Log.Page,
- rawLogText: String
- ) {
- self.jobID = jobID
- self.core = core
- self.elapsedSeconds = elapsedSeconds
- self.cancellable = cancellable
- self.logs = logs
- self.logsPage = logsPage
- self.rawLogText = rawLogText
- }
-}
-}
-
-
-package extension CodexReviewAPI.Log {
-struct PageRequest: Codable, Sendable, Hashable {
- package static let defaultLimit = 100
- package static let maxLimit = 500
- package static let `default` = CodexReviewAPI.Log.PageRequest()
-
- package var offset: Int?
- package var limit: Int
-
- package init(offset: Int? = nil, limit: Int = Self.defaultLimit) {
- self.offset = offset
- self.limit = limit
- }
-
- package func validated() throws -> CodexReviewAPI.Log.PageRequest {
- if let offset, offset < 0 {
- throw CodexReviewAPI.Error.invalidArguments("logOffset must be greater than or equal to 0.")
- }
- guard (1...Self.maxLimit).contains(limit) else {
- throw CodexReviewAPI.Error.invalidArguments("logLimit must be between 1 and \(Self.maxLimit).")
- }
- return self
- }
-
- package func page(total: Int) -> CodexReviewAPI.Log.Page {
- let resolvedOffset = if let offset {
- min(offset, total)
- } else {
- max(0, total - limit)
- }
- let returned = min(limit, max(0, total - resolvedOffset))
- let hasMoreBefore = resolvedOffset > 0
- let hasMoreAfter = resolvedOffset + returned < total
- return CodexReviewAPI.Log.Page(
- total: total,
- offset: resolvedOffset,
- limit: limit,
- returned: returned,
- hasMoreBefore: hasMoreBefore,
- hasMoreAfter: hasMoreAfter,
- previousOffset: hasMoreBefore ? max(0, resolvedOffset - limit) : nil,
- nextOffset: hasMoreAfter ? resolvedOffset + returned : nil
- )
- }
-}
-}
-
-
-package extension CodexReviewAPI.Log {
-struct Page: Codable, Sendable, Hashable {
- package var total: Int
- package var offset: Int
- package var limit: Int
- package var returned: Int
- package var hasMoreBefore: Bool
- package var hasMoreAfter: Bool
- package var previousOffset: Int?
- package var nextOffset: Int?
-
- package init(
- total: Int,
- offset: Int,
- limit: Int,
- returned: Int,
- hasMoreBefore: Bool,
- hasMoreAfter: Bool,
- previousOffset: Int?,
- nextOffset: Int?
- ) {
- self.total = total
- self.offset = offset
- self.limit = limit
- self.returned = returned
- self.hasMoreBefore = hasMoreBefore
- self.hasMoreAfter = hasMoreAfter
- self.previousOffset = previousOffset
- self.nextOffset = nextOffset
- }
-}
-}
-
-
-package extension CodexReviewAPI.Log {
-enum Filter: String, Codable, Sendable, Hashable {
- case defaultSetting = "default"
- case all
-
- package func includes(_ entry: ReviewLogEntry) -> Bool {
- switch self {
- case .defaultSetting:
- entry.kind != .commandOutput
- case .all:
- true
- }
- }
-}
-}
-
-
-package extension CodexReviewAPI.Job {
-struct ListItem: Codable, Sendable, Hashable {
- package var jobID: String
- package var cwd: String
- package var targetSummary: String
- package var core: ReviewJobCore
- package var elapsedSeconds: Int?
- package var cancellable: Bool
-
- package init(
- jobID: String,
- cwd: String,
- targetSummary: String,
- core: ReviewJobCore,
- elapsedSeconds: Int?,
- cancellable: Bool
- ) {
- self.jobID = jobID
- self.cwd = cwd
- self.targetSummary = targetSummary
- self.core = core
- self.elapsedSeconds = elapsedSeconds
- self.cancellable = cancellable
- }
-}
-}
-
-
-package extension CodexReviewAPI.List {
-struct Result: Codable, Sendable, Hashable {
- package var items: [CodexReviewAPI.Job.ListItem]
-
- package init(items: [CodexReviewAPI.Job.ListItem]) {
- self.items = items
- }
-}
-}
-
-
-package extension CodexReviewAPI.Job {
-struct Selector: Sendable, Hashable {
- package var jobID: String?
- package var cwd: String?
- package var statuses: [ReviewJobState]?
-
- package init(
- jobID: String? = nil,
- cwd: String? = nil,
- statuses: [ReviewJobState]? = nil
- ) {
- self.jobID = jobID
- self.cwd = cwd?.nilIfEmpty
- self.statuses = statuses
- }
-}
-}
-
-
-package extension CodexReviewAPI.Job {
-enum SelectionError: Swift.Error, Sendable {
- case ambiguous([CodexReviewAPI.Job.ListItem])
-}
-}
-
-
-extension CodexReviewAPI.Job.SelectionError: LocalizedError {
- package var errorDescription: String? {
- switch self {
- case .ambiguous(let jobs):
- let candidates = jobs
- .map { "- \($0.jobID) [\($0.core.lifecycle.status.rawValue)] \($0.cwd) \($0.targetSummary)" }
- .joined(separator: "\n")
- return """
- Review job selector matched multiple jobs:
- \(candidates)
- Specify jobID or narrow cwd/statuses.
- """
- }
- }
-}
-
-
-package extension CodexReviewAPI.Cancel {
-struct Outcome: Codable, Sendable, Hashable {
- package var jobID: String
- package var cancelled: Bool
- package var core: ReviewJobCore
-
- package init(
- jobID: String,
- cancelled: Bool,
- core: ReviewJobCore
- ) {
- self.jobID = jobID
- self.cancelled = cancelled
- self.core = core
- }
-}
-}
diff --git a/Sources/CodexReview/Store/CodexReviewStoreCancellation.swift b/Sources/CodexReview/Store/CodexReviewStoreCancellation.swift
deleted file mode 100644
index 49fcd752..00000000
--- a/Sources/CodexReview/Store/CodexReviewStoreCancellation.swift
+++ /dev/null
@@ -1,244 +0,0 @@
-import Foundation
-import CodexReviewDomain
-
-private actor RuntimeStopDetachedReviewWorkerDrainRace {
- private var result: Bool?
- private var continuation: CheckedContinuation?
-
- func finish(_ value: Bool) {
- guard result == nil else {
- return
- }
- result = value
- continuation?.resume(returning: value)
- continuation = nil
- }
-
- func wait() async -> Bool {
- if let result {
- return result
- }
- return await withCheckedContinuation { continuation in
- if let result {
- continuation.resume(returning: result)
- } else {
- self.continuation = continuation
- }
- }
- }
-}
-
-extension CodexReviewStore {
- package func completeCancellationLocally(
- jobID: String,
- sessionID: String,
- cancellation: ReviewCancellation = .system()
- ) throws {
- guard let job = job(id: jobID)
- else {
- throw CodexReviewAPI.Error.jobNotFound("Job \(jobID) was not found.")
- }
- guard job.sessionID == sessionID
- else {
- throw CodexReviewAPI.Error.jobNotFound("Job \(jobID) was not found.")
- }
- guard job.isTerminal == false else {
- return
- }
-
- let endedAt = clock.now()
- job.closeActiveCommandLogEntries(status: "canceled", completedAt: endedAt)
- job.cancellationRequested = false
- job.core.lifecycle.cancellation = cancellation
- job.core.lifecycle.status = .cancelled
- job.core.output.summary = cancellation.message
- job.core.output.hasFinalReview = false
- job.core.lifecycle.errorMessage = cancellation.message.nilIfEmpty
- ?? job.core.lifecycle.errorMessage
- job.core.lifecycle.endedAt = endedAt
- job.timeline.apply(.reviewCancelled(cancellation.message), at: endedAt)
- job.applyReviewLogLimit()
- noteJobMutation()
- }
-
- package func recordCancellationFailure(
- jobID: String,
- sessionID: String,
- message: String
- ) throws {
- guard let job = job(id: jobID)
- else {
- throw CodexReviewAPI.Error.jobNotFound("Job \(jobID) was not found.")
- }
- guard job.sessionID == sessionID
- else {
- throw CodexReviewAPI.Error.jobNotFound("Job \(jobID) was not found.")
- }
-
- job.cancellationRequested = false
- job.core.lifecycle.cancellation = nil
- if let message = message.nilIfEmpty {
- if message == "Failed to cancel review." {
- job.core.output.summary = message
- } else {
- job.core.output.summary = "Failed to cancel review: \(message)"
- }
- job.core.lifecycle.errorMessage = message
- } else {
- job.core.output.summary = "Failed to cancel review."
- }
- writeDiagnosticsIfNeeded()
- }
-
- public func cancelAllRunningJobs(
- reason: String = "Cancellation requested."
- ) async throws {
- let cancellation = ReviewCancellation.system(
- message: reason.nilIfEmpty ?? "Cancellation requested."
- )
- let cancellableJobs = orderedJobs.filter { $0.isTerminal == false }
- var firstError: (any Error)?
- for job in cancellableJobs {
- do {
- _ = try await cancelReview(
- jobID: job.id,
- sessionID: job.sessionID,
- cancellation: cancellation
- )
- } catch {
- let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
- try? recordCancellationFailure(
- jobID: job.id,
- sessionID: job.sessionID,
- message: message.isEmpty ? "Failed to cancel review." : message
- )
- if firstError == nil {
- firstError = error
- }
- }
- }
- if let firstError {
- throw firstError
- }
- }
-
- package func requestActiveReviewCancellationsForRuntimeStop(
- reason: ReviewCancellation = .system(message: "Review runtime stopped.")
- ) async -> [String] {
- let activeJobIDs = orderedJobs
- .filter { $0.isTerminal == false }
- .map(\.id)
- for jobID in activeJobIDs {
- _ = try? await cancelReview(jobID: jobID, cancellation: reason)
- }
- return activeJobIDs
- }
-
- @discardableResult
- package func cancelActiveReviewsLocallyForRuntimeStop(
- reason: ReviewCancellation = .system(message: "Review runtime stopped."),
- cancelWorkers: Bool = true
- ) -> [String] {
- let activeJobIDs = orderedJobs
- .filter { $0.isTerminal == false }
- .map(\.id)
- guard activeJobIDs.isEmpty == false else {
- return []
- }
-
- for jobID in activeJobIDs {
- if let job = job(id: jobID), job.isTerminal == false {
- try? completeCancellationLocally(
- jobID: job.id,
- sessionID: job.sessionID,
- cancellation: reason
- )
- }
- if cancelWorkers {
- reviewWorkerTasks[jobID]?.cancel()
- }
- }
- return activeJobIDs
- }
-
- package func cancelAndDetachReviewWorkersForRuntimeStop(jobIDs: [String]) {
- for jobID in jobIDs {
- if let task = reviewWorkerTasks.removeValue(forKey: jobID) {
- task.cancel()
- runtimeStopDetachedReviewWorkerTasks[jobID] = task
- }
- activeRuns.removeValue(forKey: jobID)
- reviewRecoveryWaitingJobIDs.remove(jobID)
- startingJobIDs.remove(jobID)
- startupCancellations.removeValue(forKey: jobID)
- }
- }
-
- package func drainRuntimeStopDetachedReviewWorkers(timeout: Duration) async -> Bool {
- let tasks = Array(runtimeStopDetachedReviewWorkerTasks.values)
- return await drainReviewWorkerTasksForRuntimeStop(tasks, timeout: timeout)
- }
-
- package func drainReviewWorkersForRuntimeStop(timeout: Duration) async -> Bool {
- let tasks = Array(reviewWorkerTasks.values) + Array(runtimeStopDetachedReviewWorkerTasks.values)
- return await drainReviewWorkerTasksForRuntimeStop(tasks, timeout: timeout)
- }
-
- private func drainReviewWorkerTasksForRuntimeStop(
- _ tasks: [Task],
- timeout: Duration
- ) async -> Bool {
- guard tasks.isEmpty == false else {
- return true
- }
-
- let race = RuntimeStopDetachedReviewWorkerDrainRace()
- let drainTask = Task {
- for task in tasks {
- await task.value
- }
- await race.finish(true)
- }
- let timeoutTask = Task {
- do {
- try await Task.sleep(for: timeout)
- } catch {
- return
- }
- await race.finish(false)
- }
-
- let didDrain = await race.wait()
- if didDrain {
- timeoutTask.cancel()
- } else {
- drainTask.cancel()
- }
- return didDrain
- }
-
- package func terminateAllRunningJobsLocally(
- reason: String = "Cancellation requested.",
- failureMessage: String
- ) {
- let resolvedError = failureMessage.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
- for job in orderedJobs where job.isTerminal == false {
- job.cancellationRequested = false
- job.core.lifecycle.cancellation = nil
- job.core.lifecycle.status = .failed
- if let resolvedError {
- job.core.output.summary = "Failed to cancel review: \(resolvedError)"
- } else {
- job.core.output.summary = "Failed to cancel review."
- }
- job.core.output.hasFinalReview = false
- job.core.lifecycle.errorMessage = resolvedError
- ?? reason.nilIfEmpty
- ?? job.core.lifecycle.errorMessage
- job.core.lifecycle.endedAt = clock.now()
- job.timeline.apply(.reviewFailed(job.core.output.summary), at: job.core.lifecycle.endedAt ?? clock.now())
- job.applyReviewLogLimit()
- }
- noteJobMutation()
- }
-}
diff --git a/Sources/CodexReview/Store/CodexReviewStoreOrderQueries.swift b/Sources/CodexReview/Store/CodexReviewStoreOrderQueries.swift
deleted file mode 100644
index a5422805..00000000
--- a/Sources/CodexReview/Store/CodexReviewStoreOrderQueries.swift
+++ /dev/null
@@ -1,125 +0,0 @@
-extension CodexReviewStore {
- package var orderedWorkspaces: [CodexReviewWorkspace] {
- workspaces.sorted {
- if $0.sortOrder == $1.sortOrder {
- return $0.cwd < $1.cwd
- }
- return $0.sortOrder > $1.sortOrder
- }
- }
-
- package var orderedJobs: [CodexReviewJob] {
- orderedWorkspaces.flatMap { orderedJobs(in: $0) }
- }
-
- package var hasReviewJobs: Bool {
- jobs.isEmpty == false
- }
-
- package func workspace(cwd: String) -> CodexReviewWorkspace? {
- workspaces.first(where: { $0.cwd == cwd })
- }
-
- package func workspace(containing job: CodexReviewJob) -> CodexReviewWorkspace? {
- workspace(cwd: job.cwd)
- }
-
- package func job(id: String) -> CodexReviewJob? {
- jobs.first(where: { $0.id == id })
- }
-
- package func jobs(inWorkspace cwd: String) -> [CodexReviewJob] {
- jobs.filter { $0.cwd == cwd }
- }
-
- package func orderedJobs(in workspace: CodexReviewWorkspace) -> [CodexReviewJob] {
- orderedJobs(inWorkspace: workspace.cwd)
- }
-
- package func orderedJobs(inWorkspace cwd: String) -> [CodexReviewJob] {
- jobs(inWorkspace: cwd).sorted {
- if $0.sortOrder == $1.sortOrder {
- return $0.id < $1.id
- }
- return $0.sortOrder > $1.sortOrder
- }
- }
-
- package func jobCount(in workspace: CodexReviewWorkspace) -> Int {
- jobs(inWorkspace: workspace.cwd).count
- }
-
- package func totalJobCount() -> Int {
- jobs.count
- }
-
- package func normalizeJobSortOrders(inWorkspace cwd: String) {
- let ordered = orderedJobs(inWorkspace: cwd)
- for (index, job) in ordered.enumerated() {
- job.sortOrder = Double(ordered.count - index - 1)
- }
- }
-
- package func normalizeWorkspaceSortOrders() {
- let ordered = orderedWorkspaces
- for (index, workspace) in ordered.enumerated() {
- workspace.sortOrder = Double(ordered.count - index - 1)
- }
- }
-
- package func normalizeAllJobSortOrders() {
- for workspace in workspaces {
- normalizeJobSortOrders(inWorkspace: workspace.cwd)
- }
- }
-}
-
-package func reorderedSortOrder(
- moving item: Item,
- toIndex destinationIndex: Int,
- in orderedItems: [Item],
- sortOrder: (Item) -> Double
-) -> Double? {
- guard let sourceIndex = orderedItems.firstIndex(where: { $0 === item }) else {
- return nil
- }
-
- var remainingItems = orderedItems
- remainingItems.remove(at: sourceIndex)
- let insertionIndex = max(0, min(destinationIndex, remainingItems.count))
- let previousSortOrder = insertionIndex > 0
- ? sortOrder(remainingItems[insertionIndex - 1])
- : nil
- let nextSortOrder = insertionIndex < remainingItems.count
- ? sortOrder(remainingItems[insertionIndex])
- : nil
-
- switch (previousSortOrder, nextSortOrder) {
- case (.some(let previous), .some(let next)):
- guard previous != next else {
- return nil
- }
- let midpoint = previous + (next - previous) / 2
- guard midpoint.isFinite,
- midpoint > min(previous, next),
- midpoint < max(previous, next)
- else {
- return nil
- }
- return midpoint
- case (.some(let previous), .none):
- let next = previous - 1
- guard next.isFinite, next != previous else {
- return nil
- }
- return next
- case (.none, .some(let next)):
- let previous = next + 1
- guard previous.isFinite, previous != next else {
- return nil
- }
- return previous
- case (.none, .none):
- return 0
- }
-}
diff --git a/Sources/CodexReview/Store/CodexReviewStoreOrdering.swift b/Sources/CodexReview/Store/CodexReviewStoreOrdering.swift
deleted file mode 100644
index 8b8c3aa5..00000000
--- a/Sources/CodexReview/Store/CodexReviewStoreOrdering.swift
+++ /dev/null
@@ -1,128 +0,0 @@
-extension CodexReviewStore {
- @discardableResult
- package func reorderWorkspaces(cwds: [String], beforeCWD: String?) -> Bool {
- let cwdSet = Set(cwds)
- guard beforeCWD.map({ cwdSet.contains($0) }) != true else {
- return false
- }
-
- let ordered = orderedWorkspaces
- let remaining = ordered.filter { cwdSet.contains($0.cwd) == false }
- let destinationIndex: Int
- if let beforeCWD {
- guard let beforeIndex = remaining.firstIndex(where: { $0.cwd == beforeCWD }) else {
- return false
- }
- destinationIndex = beforeIndex
- } else {
- destinationIndex = remaining.count
- }
- return reorderWorkspaces(cwds: cwds, toRemainingIndex: destinationIndex)
- }
-
- @discardableResult
- package func reorderWorkspaces(cwds: [String], toIndex: Int) -> Bool {
- reorderWorkspaces(cwds: cwds, toRemainingIndex: toIndex)
- }
-
- @discardableResult
- private func reorderWorkspaces(cwds: [String], toRemainingIndex toIndex: Int) -> Bool {
- let cwdSet = Set(cwds)
- let ordered = orderedWorkspaces
- let moving = ordered.filter { cwdSet.contains($0.cwd) }
- guard moving.isEmpty == false else {
- return false
- }
-
- let remaining = ordered.filter { cwdSet.contains($0.cwd) == false }
- let destinationIndex = max(0, min(toIndex, remaining.count))
- var reordered = remaining
- reordered.insert(contentsOf: moving, at: destinationIndex)
- guard reordered.count == ordered.count,
- zip(reordered, ordered).contains(where: { pair in pair.0 !== pair.1 })
- else {
- return false
- }
-
- for (index, workspace) in reordered.enumerated() {
- workspace.sortOrder = Double(reordered.count - index - 1)
- }
- writeDiagnosticsIfNeeded()
- return true
- }
-
- @discardableResult
- package func reorderJob(
- id: String,
- inWorkspace cwd: String,
- beforeJobID: String?
- ) -> Bool {
- guard beforeJobID != id,
- workspace(cwd: cwd) != nil
- else {
- return false
- }
-
- let ordered = orderedJobs(inWorkspace: cwd)
- guard let job = ordered.first(where: { $0.id == id }) else {
- return false
- }
-
- let remaining = ordered.filter { $0 !== job }
- let destinationIndex: Int
- if let beforeJobID {
- guard let beforeIndex = remaining.firstIndex(where: { $0.id == beforeJobID }) else {
- return false
- }
- destinationIndex = beforeIndex
- } else {
- destinationIndex = remaining.count
- }
- return reorderJob(id: id, inWorkspace: cwd, toIndex: destinationIndex)
- }
-
- @discardableResult
- package func reorderJob(
- id: String,
- inWorkspace cwd: String,
- toIndex: Int
- ) -> Bool {
- guard workspace(cwd: cwd) != nil else {
- return false
- }
-
- let ordered = orderedJobs(inWorkspace: cwd)
- guard let job = ordered.first(where: { $0.id == id }),
- let sourceIndex = ordered.firstIndex(where: { $0 === job })
- else {
- return false
- }
-
- let destinationIndex = max(0, min(toIndex, ordered.count - 1))
- guard sourceIndex != destinationIndex else {
- return false
- }
-
- var sortOrder = reorderedSortOrder(
- moving: job,
- toIndex: destinationIndex,
- in: ordered,
- sortOrder: \.sortOrder
- )
- if sortOrder == nil {
- normalizeJobSortOrders(inWorkspace: cwd)
- sortOrder = reorderedSortOrder(
- moving: job,
- toIndex: destinationIndex,
- in: orderedJobs(inWorkspace: cwd),
- sortOrder: \.sortOrder
- )
- }
- guard let sortOrder else {
- return false
- }
- job.sortOrder = sortOrder
- writeDiagnosticsIfNeeded()
- return true
- }
-}
diff --git a/Sources/CodexReview/Store/CodexReviewStoreTesting.swift b/Sources/CodexReview/Store/CodexReviewStoreTesting.swift
deleted file mode 100644
index c6f20a44..00000000
--- a/Sources/CodexReview/Store/CodexReviewStoreTesting.swift
+++ /dev/null
@@ -1,102 +0,0 @@
-import Foundation
-
-extension CodexReviewStore {
- nonisolated(unsafe) private static var requestCancellationDelayForTestingStorage: TimeInterval = 0
- nonisolated(unsafe) package static var requestCancellationDelay: TimeInterval {
- get { requestCancellationDelayForTestingStorage }
- set { requestCancellationDelayForTestingStorage = max(0, newValue) }
- }
-
- @_spi(Testing)
- public static var requestCancellationDelayForTesting: TimeInterval {
- get { requestCancellationDelay }
- set { requestCancellationDelay = newValue }
- }
-
- package func loadForTesting(
- serverState: CodexReviewServerState,
- authPhase: CodexReviewAuthModel.Phase = .signedOut,
- account: CodexAccount? = nil,
- persistedAccounts: [CodexAccount]? = nil,
- serverURL: URL? = nil,
- workspaces: [CodexReviewWorkspace],
- jobs: [CodexReviewJob] = [],
- settingsSnapshot: CodexReviewSettings.Snapshot? = nil
- ) {
- precondition(
- backend.isActive == false,
- "loadForTesting must be called before the embedded server starts."
- )
- self.serverState = serverState
- self.auth.updatePhase(authPhase)
- let resolvedPersistedAccounts = persistedAccounts ?? account.map { [$0] } ?? []
- self.auth.applyPersistedAccountStates(
- resolvedPersistedAccounts.map(savedAccountPayload(from:))
- )
- if let account,
- resolvedPersistedAccounts.contains(where: { $0.accountKey == account.accountKey })
- {
- self.auth.selectPersistedAccount(account.id)
- } else {
- self.auth.updateCurrentAccount(account)
- }
- self.serverURL = serverURL
- var existingByCWD: [String: CodexReviewWorkspace] = [:]
- for workspace in self.workspaces {
- existingByCWD[workspace.cwd] = workspace
- }
-
- var resolvedWorkspaces: [CodexReviewWorkspace] = []
- resolvedWorkspaces.reserveCapacity(workspaces.count)
-
- for (workspaceIndex, workspace) in workspaces.enumerated() {
- let sortOrder = Double(workspaces.count - workspaceIndex - 1)
- if let existingWorkspace = existingByCWD.removeValue(forKey: workspace.cwd) {
- existingWorkspace.sortOrder = sortOrder
- resolvedWorkspaces.append(existingWorkspace)
- } else {
- workspace.sortOrder = sortOrder
- resolvedWorkspaces.append(workspace)
- }
- }
-
- self.workspaces = Set(resolvedWorkspaces)
- var jobsByCWD: [String: [CodexReviewJob]] = [:]
- let resolvedJobs = jobs.filter { job in
- resolvedWorkspaces.contains(where: { $0.cwd == job.cwd })
- }
- for job in resolvedJobs {
- jobsByCWD[job.cwd, default: []].append(job)
- }
- for job in resolvedJobs {
- guard let workspaceJobs = jobsByCWD[job.cwd],
- let index = workspaceJobs.firstIndex(where: { $0 === job })
- else {
- continue
- }
- job.sortOrder = Double(workspaceJobs.count - index - 1)
- }
- self.jobs = Set(resolvedJobs)
- if let settingsSnapshot {
- settings.loadForTesting(snapshot: settingsSnapshot)
- }
- writeDiagnosticsIfNeeded()
- }
-
- package func cancelAndDrainReviewWorkersForTesting() async {
- let tasks = Array(reviewWorkerTasks.values) + Array(runtimeStopDetachedReviewWorkerTasks.values)
- for task in tasks {
- task.cancel()
- }
- for task in tasks {
- await task.value
- }
-
- reviewWorkerTasks.removeAll(keepingCapacity: false)
- runtimeStopDetachedReviewWorkerTasks.removeAll(keepingCapacity: false)
- startingJobIDs.removeAll(keepingCapacity: false)
- startupCancellations.removeAll(keepingCapacity: false)
- activeRuns.removeAll(keepingCapacity: false)
- reviewRecoveryWaitingJobIDs.removeAll(keepingCapacity: false)
- }
-}
diff --git a/Sources/CodexReviewAppServer/AppServerClient.swift b/Sources/CodexReviewAppServer/AppServerClient.swift
deleted file mode 100644
index e3ba083c..00000000
--- a/Sources/CodexReviewAppServer/AppServerClient.swift
+++ /dev/null
@@ -1,218 +0,0 @@
-import Foundation
-import OSLog
-
-private let logger = Logger(subsystem: "CodexReviewKit", category: "app-server-client")
-
-package actor AppServerClient {
- private static let appServerOverloadedErrorCode = -32001
- private static let overloadRetryDelays: [Duration] = [
- .milliseconds(100),
- .milliseconds(250),
- .milliseconds(500),
- ]
-
- private let transport: any JSONRPC.Transport
- private let overloadRetryDelay: @Sendable (Int) -> Duration?
- private let retrySleep: @Sendable (Duration) async throws -> Void
- private let serializer = RequestSerializer()
- private let encoder = JSONEncoder()
- private let decoder = JSONDecoder()
- private var nextRequestID = 1
- private var initializationResponse: AppServerAPI.Initialize.Response?
- private var initializationTask: Task?
-
- package init(
- transport: any JSONRPC.Transport,
- overloadRetryDelay: @escaping @Sendable (Int) -> Duration? = AppServerClient.defaultOverloadRetryDelay,
- retrySleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) }
- ) {
- self.transport = transport
- self.overloadRetryDelay = overloadRetryDelay
- self.retrySleep = retrySleep
- }
-
- package func initialize(
- clientName: String = "CodexReviewKit",
- clientVersion: String = "2"
- ) async throws -> AppServerAPI.Initialize.Response {
- if let initializationResponse {
- return initializationResponse
- }
- if let initializationTask {
- return try await initializationTask.value
- }
- let task = Task {
- try await self.performInitialize(clientName: clientName, clientVersion: clientVersion)
- }
- initializationTask = task
- do {
- let response = try await task.value
- initializationResponse = response
- initializationTask = nil
- return response
- } catch {
- initializationTask = nil
- throw error
- }
- }
-
- 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)
- ))
- try await notify(method: "initialized", params: EmptyResponse())
- logger.info("codex app-server connection initialized")
- return response
- }
-
- package func send(_ request: Request) async throws -> Request.Response {
- try await send(
- method: Request.method,
- params: request.params,
- responseType: Request.Response.self,
- scope: request.scope
- )
- }
-
- package func send(
- method: String,
- params: Params,
- responseType: Response.Type,
- scope: AppServerAPI.RequestScope? = nil
- ) async throws -> Response {
- try await serializer.run(scope: scope) { [transport, encoder, decoder, self] in
- let encodedParams = try encoder.encode(params)
- var retryAttempt = 0
- while true {
- let requestID = await self.allocateRequestID()
- logger.debug("JSON-RPC request \(requestID, privacy: .public) -> \(method, privacy: .public)")
- do {
- let rawResponse = try await transport.send(.init(
- id: requestID,
- method: method,
- params: encodedParams
- ))
- let response = try decoder.decode(responseType, from: rawResponse)
- logger.debug("JSON-RPC response \(requestID, privacy: .public) <- \(method, privacy: .public)")
- return response
- } catch let error as JSONRPC.Error {
- guard Self.isAppServerOverload(error),
- let delay = overloadRetryDelay(retryAttempt)
- else {
- logger.error("JSON-RPC request \(requestID, privacy: .public) failed for \(method, privacy: .public): \(error.localizedDescription, privacy: .public)")
- throw error
- }
- retryAttempt += 1
- logger.warning("JSON-RPC request \(requestID, privacy: .public) overloaded for \(method, privacy: .public); retrying in \(String(describing: delay), privacy: .public)")
- try await retrySleep(delay)
- } catch {
- logger.error("JSON-RPC request \(requestID, privacy: .public) failed for \(method, privacy: .public): \(error.localizedDescription, privacy: .public)")
- throw error
- }
- }
- }
- }
-
- package func notify(
- method: String,
- params: Params
- ) async throws {
- let encodedParams = try encoder.encode(params)
- logger.debug("JSON-RPC notification -> \(method, privacy: .public)")
- try await transport.notify(.init(
- method: method,
- params: encodedParams
- ))
- }
-
- package func notificationStream() async -> AsyncThrowingStream {
- await transport.notificationStream()
- }
-
- package func close() async {
- await transport.close()
- }
-
- private func allocateRequestID() -> Int {
- defer { nextRequestID += 1 }
- return nextRequestID
- }
-
- private nonisolated static func isAppServerOverload(_ error: JSONRPC.Error) -> Bool {
- if case .responseError(let code, _) = error {
- return code == appServerOverloadedErrorCode
- }
- return false
- }
-
- 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
- }
-}
-
-package actor RequestSerializer {
- private var lanes: [AppServerAPI.RequestScope: SerialLane] = [:]
-
- package init() {}
-
- package func run(
- scope: AppServerAPI.RequestScope?,
- operation: @Sendable () async throws -> Output
- ) async throws -> Output {
- guard let scope else {
- return try await operation()
- }
- let lane = lane(for: scope)
- await lane.enter()
- do {
- let output = try await operation()
- await lane.leave()
- return output
- } catch {
- await lane.leave()
- throw error
- }
- }
-
- private func lane(for scope: AppServerAPI.RequestScope) -> SerialLane {
- if let lane = lanes[scope] {
- return lane
- }
- let lane = SerialLane()
- lanes[scope] = lane
- return lane
- }
-}
-
-private actor SerialLane {
- private var isOccupied = false
- private var waiters: [CheckedContinuation] = []
-
- func enter() async {
- if isOccupied == false {
- isOccupied = true
- return
- }
- await withCheckedContinuation { continuation in
- waiters.append(continuation)
- }
- }
-
- func leave() {
- if waiters.isEmpty {
- isOccupied = false
- } else {
- let next = waiters.removeFirst()
- next.resume()
- }
- }
-}
diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift
index 4e2d823e..ac700635 100644
--- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift
+++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift
@@ -1,7 +1,7 @@
import Foundation
-import CodexReview
-import CodexReviewAppServerWire
-import CodexReviewDomain
+import CodexKit
+import CodexAppServerKit
+import CodexReviewKit
import OSLog
private let appServerBackendLogger = Logger(
@@ -9,288 +9,176 @@ private let appServerBackendLogger = Logger(
category: "app-server-backend"
)
-private func appServerTurnThreadID(for run: CodexReviewBackendModel.Review.Run) -> String {
- run.reviewThreadID?.nilIfEmpty ?? run.threadID
-}
-
private func makeAppServerReviewAttemptID() -> String {
UUID().uuidString
}
-package actor AppServerCodexReviewBackend: CodexReviewBackend {
+package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor {
private static let reviewPermissionProfileID = ":danger-full-access"
- private let client: AppServerClient
- private let threadStartPermissionStrategy: AppServerAPI.Thread.Start.PermissionStrategy
- private var controlsByThreadID: [String: AppServerReviewControl] = [:]
+ private let appServer: CodexAppServer
+ nonisolated package let modelContainer: CodexModelContainer
+ nonisolated package let modelExecutor: any CodexModelExecutor
private var reviewEventSessionsByAttemptID: [String: AppServerReviewEventSession] = [:]
private var activeReviewAttemptIDByThreadID: [String: String] = [:]
private var activeThreadIDsByAttemptID: [String: Set] = [:]
private var reviewEventSessionCanonicalThreadIDByThreadID: [String: String] = [:]
- private var reviewThreadIDsForCleanupByThreadID: [String: Set] = [:]
private var abandonedReviewAttemptIDs: Set = []
- private var abandonedTurnIDs: Set = []
- private var unmatchedReviewNotificationsByThreadID: [String: [AppServerRoutedReviewNotification]] = [:]
- private var completedReviewEventSessionMetricsByThreadID: [String: AppServerReviewEventSessionMetrics] = [:]
- private var notificationRouterTask: Task?
- private var isNotificationRouterStarting = false
- private var reviewNotificationSequence = 0
- private var notificationRouterMetrics = AppServerNotificationRouterMetrics()
- private var reviewStartRequestsInFlight = 0
-
- package init(
- client: AppServerClient,
- threadStartPermissionStrategy: AppServerAPI.Thread.Start.PermissionStrategy = .modernPermissions
- ) {
- self.client = client
- self.threadStartPermissionStrategy = threadStartPermissionStrategy
+ private var inFlightRestartCountByInterruptedAttemptID: [String: Int] = [:]
+ private var completedReviewEventSessionMetricsByThreadID: [String: ReviewBackendEventSessionMetrics] = [:]
+
+ private struct DeferredReviewThreadCleanup {
+ var run: CodexReviewBackendModel.Review.Run
+ var additionalCleanupThreadIDs: [String]
+ }
+
+ // Terminal reviews keep their chats readable (sidebar, MCP log reads)
+ // until runtime teardown: cleanupReview defers the destructive thread
+ // deletion here and cleanupActiveReviewsForShutdown flushes it.
+ private var deferredThreadCleanupsByAttemptID: [String: DeferredReviewThreadCleanup] = [:]
+
+ package init(appServer: CodexAppServer, modelContainer: CodexModelContainer? = nil) {
+ let modelContainer = modelContainer ?? CodexModelContainer(appServer: appServer)
+ self.appServer = appServer
+ self.modelContainer = modelContainer
+ self.modelExecutor = CodexDefaultSerialModelExecutor(modelContainer: modelContainer)
}
package func readSettings() async throws -> CodexReviewBackendModel.Settings.Snapshot {
- _ = try await client.initialize()
- let response = try await client.send(AppServerAPI.Config.Read.Request())
- let models = try await readModelCatalog()
+ let configuration = try await appServer.configuration()
+ let models = try await appServer.models(includeHidden: true)
+ .map(\.reviewModelCatalogItem)
return .init(
- model: response.config.reviewModel?.nilIfEmpty,
- fallbackModel: response.config.model?.nilIfEmpty ?? models.first(where: \.isDefault)?.model,
- reasoningEffort: response.config.modelReasoningEffort,
- serviceTier: response.config.serviceTier,
+ model: configuration.reviewModel?.nilIfEmpty,
+ fallbackModel: configuration.model?.nilIfEmpty ?? models.first(where: \.isDefault)?.model,
+ reasoningEffort: configuration.reasoningEffort?.rawValue,
+ serviceTier: configuration.serviceTier,
models: models
)
}
- package func applySettings(_ change: CodexReviewBackendModel.Settings.Change) async throws -> CodexReviewBackendModel.Settings.Snapshot {
- _ = try await client.initialize()
- let edits = Self.configEdits(from: change)
- if edits.isEmpty == false {
- let _: AppServerAPI.Config.BatchWrite.Response = try await client.send(AppServerAPI.Config.BatchWrite.Request(
- params: .init(edits: edits)
- ))
+ package func applySettings(_ change: CodexReviewBackendModel.Settings.Change) async throws
+ -> CodexReviewBackendModel.Settings.Snapshot
+ {
+ var patch = CodexConfigurationPatch()
+ if change.updatesModel {
+ patch.setReviewModel(change.model?.nilIfEmpty)
}
+ if change.updatesReasoningEffort {
+ patch.setReasoningEffort(change.reasoningEffort?.nilIfEmpty.map(CodexReasoningEffort.init(rawValue:)))
+ }
+ if change.updatesServiceTier {
+ patch.setServiceTier(change.serviceTier?.nilIfEmpty)
+ }
+ try await appServer.updateConfiguration(patch)
return try await readSettings()
}
package func readAuth() async throws -> CodexReviewBackendModel.Auth.Snapshot {
- _ = try await client.initialize()
- let response = try await client.send(AppServerAPI.Auth.Read.Request())
- guard let account = response.account?.backendAccount else {
+ guard let account = try await appServer.account() else {
return .init()
}
- return .init(accounts: [account], activeAccountID: account.id)
+ let backendAccount = account.backendAccount
+ return .init(accounts: [backendAccount], activeAccountID: backendAccount.id)
}
- package func readRateLimits() async throws -> AppServerAPI.Account.RateLimits.Response {
- _ = try await client.initialize()
- return try await client.send(AppServerAPI.Account.RateLimits.Read.Request())
+ package func readRateLimits() async throws -> CodexRateLimits {
+ try await appServer.rateLimits()
}
- package func startLogin(_ request: CodexReviewBackendModel.Login.Request) async throws -> CodexReviewBackendModel.Login.Challenge {
- _ = try await client.initialize()
- let nativeWebAuthentication = request.nativeWebAuthenticationCallbackScheme
- .map(AppServerAPI.Account.Login.NativeWebAuthentication.init(callbackURLScheme:))
- let response: AppServerAPI.Account.Login.Response = try await client.send(
- method: "account/login/start",
- params: AppServerAPI.Account.Login.Params(nativeWebAuthentication: nativeWebAuthentication),
- responseType: AppServerAPI.Account.Login.Response.self
+ package func startLogin(_: CodexReviewBackendModel.Login.Request) async throws
+ -> CodexReviewBackendModel.Login.Challenge
+ {
+ // CodexAppServerKit's loginChatGPT cannot yet forward the native
+ // web-authentication callback scheme to account/login/start, so the
+ // server never shapes the auth URL for a native session; the host
+ // deliberately falls back to the external browser until that API
+ // exists. Do not mark the challenge native here without it.
+ let handle = try await appServer.loginChatGPT()
+ return try handle.backendChallenge(
+ nativeWebAuthenticationCallbackScheme: nil
)
- return try response.backendChallenge
}
package func cancelLogin(_ challenge: CodexReviewBackendModel.Login.Challenge) async throws {
- _ = try await client.initialize()
- let _: AppServerAPI.Account.Login.Cancel.Response = try await client.send(
- method: "account/login/cancel",
- params: AppServerAPI.Account.Login.Cancel.Params(loginID: challenge.id),
- responseType: AppServerAPI.Account.Login.Cancel.Response.self
- )
- }
-
- package func completeLogin(_ response: CodexReviewBackendModel.Login.Response) async throws -> CodexReviewBackendModel.Auth.Snapshot {
- if let callbackURL = response.callbackURL {
- _ = try await client.initialize()
- let _: AppServerAPI.Account.Login.Complete.Response = try await client.send(
- method: "account/login/complete",
- params: AppServerAPI.Account.Login.Complete.Params(loginID: response.challengeID, callbackURL: callbackURL),
- responseType: AppServerAPI.Account.Login.Complete.Response.self
- )
- }
- return try await readAuth()
+ try await appServer.cancelLogin(id: .init(rawValue: challenge.id))
}
package func logout(_: CodexReviewBackendModel.Account.ID) async throws -> CodexReviewBackendModel.Auth.Snapshot {
- _ = try await client.initialize()
- let _: EmptyResponse = try await client.send(
- method: "account/logout",
- params: EmptyResponse(),
- responseType: EmptyResponse.self
- )
+ try await appServer.logout()
return try await readAuth()
}
package func startReview(_ request: CodexReviewBackendModel.Review.Start) async throws -> BackendReviewAttempt {
- _ = try await client.initialize()
- await ensureNotificationRouterStarted()
- let control = AppServerReviewControl(client: client)
-
- let thread = try await startReviewThread(request)
- controlsByThreadID[thread.threadID] = control
+ let review = try await startReviewSession(for: request)
let attemptID = makeAppServerReviewAttemptID()
- let provisionalRun = CodexReviewBackendModel.Review.Run(
- attemptID: attemptID,
- threadID: thread.threadID,
- reviewThreadID: thread.threadID,
- model: thread.model ?? request.model
- )
- let session = AppServerReviewEventSession(
- run: provisionalRun,
- control: control,
- isRunFinalized: false
- )
- registerReviewEventSession(session, for: provisionalRun)
- control.recordThreadStarted(threadID: thread.threadID)
-
- let review: AppServerAPI.Review.Start.Response
- reviewStartRequestsInFlight += 1
- do {
- review = try await client.send(AppServerAPI.Review.Start.Request(
- params: .init(threadID: thread.threadID, target: request.request.target)
- ))
- } catch {
- reviewStartRequestsInFlight -= 1
- discardUnmatchedReviewNotificationsIfIdle()
- await cleanupReview(provisionalRun)
- throw error
- }
- let reviewThreadID = review.reviewThreadID ?? thread.threadID
let run = CodexReviewBackendModel.Review.Run(
attemptID: attemptID,
- threadID: thread.threadID,
- turnID: review.turnID,
- reviewThreadID: reviewThreadID,
- model: thread.model ?? request.model
+ threadID: review.threadID.rawValue,
+ turnID: review.turnID.rawValue,
+ reviewThreadID: review.reviewThreadID.rawValue,
+ model: review.model ?? request.model
)
- await session.updateRun(run)
+ let session = AppServerReviewEventSession(run: run)
registerReviewEventSession(session, for: run)
- control.recordReviewStarted(turnThreadID: appServerTurnThreadID(for: run), turnID: review.turnID)
- await session.bufferStartupNotifications(takeUnmatchedReviewNotifications(for: run))
- await session.finalizeRun()
- reviewStartRequestsInFlight -= 1
- discardUnmatchedReviewNotificationsIfIdle()
+ await session.startConsuming(review)
return await session.attempt()
}
- private func startReviewThread(_ request: CodexReviewBackendModel.Review.Start) async throws -> AppServerAPI.Thread.Start.Response {
- if threadStartPermissionStrategy == .legacySandbox {
- // Deprecated compatibility: installed Codex builds without the app-server v2
- // session-source flag can ignore permissions without failing the request.
- return try await client.send(AppServerAPI.Thread.Start.Request(
- params: threadStartParamsWithLegacySandbox(request)
- ))
- }
- do {
- return try await startReviewThreadWithProfileIDPermissions(request)
- } catch let error as JSONRPC.Error where Self.shouldRetryThreadStartWithLegacySandbox(error) {
- // Deprecated compatibility: some builds accept the permissions field shape
- // without registering the danger-full-access built-in profile.
- return try await client.send(AppServerAPI.Thread.Start.Request(
- params: threadStartParamsWithLegacySandbox(request)
- ))
- } catch let error as JSONRPC.Error where Self.shouldRetryThreadStartWithObjectPermissions(error) {
- // Deprecated compatibility: installed Codex builds can require object-shaped
- // permissions while the latest local app-server source accepts a profile ID string.
- return try await startReviewThreadWithProfileSelectionPermissions(request)
- }
+ private func startReviewSession(for request: CodexReviewBackendModel.Review.Start) async throws
+ -> CodexReviewSession
+ {
+ let workspace = URL(fileURLWithPath: request.request.cwd, isDirectory: true)
+ return try await startDataKitBackedReviewSession(request, in: workspace)
}
- private func startReviewThreadWithProfileIDPermissions(
- _ request: CodexReviewBackendModel.Review.Start
- ) async throws -> AppServerAPI.Thread.Start.Response {
- try await client.send(AppServerAPI.Thread.Start.Request(
- params: threadStartParams(
- request,
- permissions: .profileID(Self.reviewPermissionProfileID)
+ private func startDataKitBackedReviewSession(
+ _ request: CodexReviewBackendModel.Review.Start,
+ in workspace: URL
+ ) async throws -> CodexReviewSession {
+ try await modelContext.startReview(
+ in: workspace,
+ input: CodexReviewInput(
+ target: request.request.target.appServerReviewTarget,
+ options: reviewThreadOptions(request)
)
- ))
+ )
+ .session
}
- private func startReviewThreadWithProfileSelectionPermissions(
+ private func reviewThreadOptions(
_ request: CodexReviewBackendModel.Review.Start
- ) async throws -> AppServerAPI.Thread.Start.Response {
- do {
- return try await client.send(AppServerAPI.Thread.Start.Request(
- params: threadStartParams(
- request,
- permissions: .profileSelection(.init(id: Self.reviewPermissionProfileID))
- )
- ))
- } catch let error as JSONRPC.Error
- where Self.shouldRetryThreadStartWithLegacySandbox(error)
- {
- // Deprecated compatibility: installed Codex builds can know the permissions
- // object shape without registering the danger-full-access built-in profile.
- return try await client.send(AppServerAPI.Thread.Start.Request(
- params: threadStartParamsWithLegacySandbox(request)
- ))
- }
- }
-
- private func threadStartParams(
- _ request: CodexReviewBackendModel.Review.Start,
- permissions: AppServerAPI.Thread.Start.Permissions
- ) -> AppServerAPI.Thread.Start.Params {
- .init(
- cwd: request.request.cwd,
- model: request.model,
- ephemeral: false,
- approvalPolicy: "never",
- permissions: permissions,
- sessionStartSource: .startup,
- threadSource: .user
- )
+ ) -> CodexThread.Options {
+ reviewThreadOptions(model: request.model)
}
- private func threadStartParamsWithLegacySandbox(_ request: CodexReviewBackendModel.Review.Start) -> AppServerAPI.Thread.Start.Params {
+ // Every thread handle a review runs on uses the same review profile;
+ // restart/resume paths must not fall back to default Codex settings.
+ private func reviewThreadOptions(model: String?) -> CodexThread.Options {
.init(
- cwd: request.request.cwd,
- model: request.model,
+ model: model,
+ approvalMode: .denyAll,
+ permissions: .profile(id: Self.reviewPermissionProfileID),
ephemeral: false,
- approvalPolicy: "never",
- sandbox: "danger-full-access",
sessionStartSource: .startup,
threadSource: .user
)
}
- private nonisolated static func shouldRetryThreadStartWithObjectPermissions(_ error: JSONRPC.Error) -> Bool {
- guard case .responseError(_, let message) = error else {
- return false
- }
- return message.contains("PermissionProfileSelectionParams")
- || message.contains("invalid type: string")
- }
-
- private nonisolated static func shouldRetryThreadStartWithLegacySandbox(_ error: JSONRPC.Error) -> Bool {
- guard case .responseError(_, let message) = error else {
- return false
- }
- return message.contains("unknown built-in profile")
- || message.contains("default_permissions refers to unknown")
- }
-
- package func interruptReview(_ run: CodexReviewBackendModel.Review.Run, reason: CodexReviewBackendModel.CancellationReason) async throws {
- _ = try await client.initialize()
+ package func interruptReview(
+ _ run: CodexReviewBackendModel.Review.Run, reason: CodexReviewBackendModel.CancellationReason
+ ) async throws {
guard abandonedReviewAttemptIDs.contains(run.attemptID) == false else {
return
}
let session = await reviewEventSession(for: run)
await session.requestCancellation(message: reason.message)
do {
- _ = try await sendTurnInterrupt(for: run)
+ _ = try await cancelReviewTurn(for: run)
await finishReviewEventStream(
threadID: run.threadID,
- cancellationMessage: reason.message,
- buffersMissingContinuation: true
+ cancellationMessage: reason.message
)
} catch {
await session.clearCancellationRequest()
@@ -298,159 +186,128 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend {
}
}
- package func beginReviewRecovery(
- _ run: CodexReviewBackendModel.Review.Run,
- reason _: CodexReviewBackendModel.CancellationReason
- ) async throws -> CodexReviewBackendModel.Review.RecoveryToken {
- _ = try await client.initialize()
- await ensureNotificationRouterStarted()
- markTurnAbandoned(run.turnID)
- let interruption = try await sendTurnInterrupt(for: run) { retryInterruption in
- await self.markInterruptionTurnAbandoned(retryInterruption, canonicalThreadID: run.threadID)
+ package func prepareReviewRestart(
+ _ run: CodexReviewBackendModel.Review.Run
+ ) async throws -> CodexReviewBackendModel.Review.RestartToken {
+ guard let identity = run.appServerReviewIdentity else {
+ throw CodexReviewAPI.Error.io("Review run has no restartable app-server turn.")
}
- markAttemptAbandoned(run, interruption: interruption)
+ let appServerToken = try await appServer.prepareReviewRestart(identity)
+ markAttemptAbandoned(run)
if let session = unregisterReviewEventSession(for: run) {
await session.abandon()
let metrics = await session.metricsSnapshot()
- for threadID in await session.cleanupThreadIDs() {
+ for threadID in localCleanupThreadIDs(for: run, additional: await session.cleanupThreadIDs()) {
completedReviewEventSessionMetricsByThreadID[threadID] = metrics
}
}
- return CodexReviewBackendModel.Review.RecoveryToken(
- interruptedRun: run,
- rollbackThreadID: interruption.threadID
+ return CodexReviewBackendModel.Review.RestartToken(
+ id: appServerToken.id,
+ interruptedRun: run
)
}
- package func resumeReviewRecovery(
- _ token: CodexReviewBackendModel.Review.RecoveryToken,
+ package func restartPreparedReview(
+ _ token: CodexReviewBackendModel.Review.RestartToken,
request: CodexReviewBackendModel.Review.Start
) async throws -> BackendReviewAttempt {
- _ = try await client.initialize()
- await ensureNotificationRouterStarted()
let interruptedRun = token.interruptedRun
- let _: EmptyResponse = try await client.send(AppServerAPI.Thread.Rollback.Request(
- params: .init(threadID: token.rollbackThreadID, numTurns: 1)
- ))
-
- let control = controlsByThreadID[interruptedRun.threadID] ?? AppServerReviewControl(client: client)
- controlsByThreadID[interruptedRun.threadID] = control
- let attemptID = makeAppServerReviewAttemptID()
- let provisionalRun = CodexReviewBackendModel.Review.Run(
- attemptID: attemptID,
- threadID: interruptedRun.threadID,
- reviewThreadID: interruptedRun.threadID,
- model: interruptedRun.model ?? request.model
- )
- let session = AppServerReviewEventSession(
- run: provisionalRun,
- control: control,
- isRunFinalized: false
+ guard let interruptedIdentity = interruptedRun.appServerReviewIdentity else {
+ throw CodexReviewAPI.Error.io("Prepared review restart has no app-server identity.")
+ }
+ let appServerToken = CodexReviewRestartToken(
+ id: token.id,
+ interruptedIdentity: interruptedIdentity
)
- registerReviewEventSession(session, for: provisionalRun)
-
- let review: AppServerAPI.Review.Start.Response
- reviewStartRequestsInFlight += 1
- do {
- review = try await client.send(AppServerAPI.Review.Start.Request(
- params: .init(threadID: interruptedRun.threadID, target: request.request.target)
- ))
- } catch {
- reviewStartRequestsInFlight -= 1
- _ = unregisterReviewEventSession(for: provisionalRun)
- await session.abandon()
- discardUnmatchedReviewNotificationsIfIdle()
- throw error
+ markRestartInFlight(forInterrupted: interruptedRun)
+ defer {
+ clearRestartInFlight(forInterrupted: interruptedRun)
}
-
+ let review = try await appServer.restartPreparedReview(
+ appServerToken,
+ target: request.request.target.appServerReviewTarget,
+ threadOptions: reviewThreadOptions(model: interruptedRun.model ?? request.model)
+ )
+ let attemptID = makeAppServerReviewAttemptID()
let recoveredRun = CodexReviewBackendModel.Review.Run(
attemptID: attemptID,
threadID: interruptedRun.threadID,
- turnID: review.turnID,
- reviewThreadID: review.reviewThreadID ?? interruptedRun.threadID,
- model: interruptedRun.model ?? request.model
+ turnID: review.turnID.rawValue,
+ reviewThreadID: review.reviewThreadID.rawValue,
+ model: review.model ?? interruptedRun.model ?? request.model
)
- await session.updateRun(recoveredRun)
+ let session = AppServerReviewEventSession(run: recoveredRun)
registerReviewEventSession(session, for: recoveredRun)
- controlsByThreadID[interruptedRun.threadID]?.recordReviewStarted(
- turnThreadID: appServerTurnThreadID(for: recoveredRun),
- turnID: review.turnID
- )
- await session.bufferStartupNotifications(takeUnmatchedReviewNotifications(for: recoveredRun))
- await session.finalizeRun()
- reviewStartRequestsInFlight -= 1
- discardUnmatchedReviewNotificationsIfIdle()
+ await session.startConsuming(review)
return await session.attempt()
}
package func cleanupReview(_ run: CodexReviewBackendModel.Review.Run) async {
- _ = try? await client.initialize()
- controlsByThreadID.removeValue(forKey: run.threadID)
- var cleanupThreadIDs = cleanupThreadIDs(for: run)
+ var completedMetrics: ReviewBackendEventSessionMetrics?
+ var additionalCleanupThreadIDs: [String] = []
if let session = unregisterReviewEventSession(for: run) {
await session.finish(cancellationMessage: nil)
let metrics = await session.metricsSnapshot()
- cleanupThreadIDs = mergedCleanupThreadIDs(cleanupThreadIDs, await session.cleanupThreadIDs())
+ additionalCleanupThreadIDs = await session.cleanupThreadIDs()
+ completedMetrics = metrics
+ }
+ let cleanupThreadIDs = localCleanupThreadIDs(for: run, additional: additionalCleanupThreadIDs)
+ if let completedMetrics {
for threadID in cleanupThreadIDs {
- completedReviewEventSessionMetricsByThreadID[threadID] = metrics
+ completedReviewEventSessionMetricsByThreadID[threadID] = completedMetrics
}
}
- let _: EmptyResponse? = try? await client.send(AppServerAPI.Thread.BackgroundTerminals.Clean.Request(
- params: .init(threadID: run.threadID)
- ))
- let _: AppServerAPI.Thread.Unsubscribe.Response? = try? await client.send(AppServerAPI.Thread.Unsubscribe.Request(
- params: .init(threadID: run.threadID)
- ))
- for threadID in cleanupThreadIDs {
- let _: EmptyResponse? = try? await client.send(AppServerAPI.Thread.Delete.Request(
- params: .init(threadID: threadID)
- ))
- }
+ deferredThreadCleanupsByAttemptID[run.attemptID] = .init(
+ run: run,
+ additionalCleanupThreadIDs: additionalCleanupThreadIDs
+ )
for threadID in cleanupThreadIDs {
reviewEventSessionCanonicalThreadIDByThreadID.removeValue(forKey: threadID)
activeReviewAttemptIDByThreadID.removeValue(forKey: threadID)
}
- reviewThreadIDsForCleanupByThreadID.removeValue(forKey: run.threadID)
}
- package func cleanupActiveReviewsForShutdown(reason: CodexReviewBackendModel.CancellationReason) async {
- let runs = await activeReviewRunsForShutdown()
- guard runs.isEmpty == false else {
- return
+ private func flushDeferredThreadCleanups() async {
+ let deferredCleanups = deferredThreadCleanupsByAttemptID.values
+ deferredThreadCleanupsByAttemptID = [:]
+ for cleanup in deferredCleanups {
+ await cleanupAppServerReview(
+ cleanup.run,
+ additionalCleanupThreadIDs: cleanup.additionalCleanupThreadIDs
+ )
}
+ }
+
+ package func cleanupActiveReviewsForShutdown(_ request: CodexReviewRuntimeStopReviewCleanupRequest) async {
+ let runs = await activeReviewRunsForShutdown()
+ var cleanedAttemptIDs: Set = []
for run in runs {
if Task.isCancelled {
return
}
- try? await interruptReview(run, reason: reason)
+ try? await interruptReview(run, reason: request.reason)
if Task.isCancelled {
return
}
await cleanupReview(run)
+ cleanedAttemptIDs.insert(run.attemptID)
}
- }
-
- package func interruptActiveReviewsForShutdown(reason: CodexReviewBackendModel.CancellationReason) async {
- let runs = await activeReviewRunsForShutdown()
- guard runs.isEmpty == false else {
- return
- }
- for run in runs {
+ for run in request.recoveryWaitingRuns where cleanedAttemptIDs.insert(run.attemptID).inserted {
+ if isRestartInFlight(forInterrupted: run) {
+ continue
+ }
if Task.isCancelled {
return
}
- try? await interruptReview(run, reason: reason)
+ await cleanupReview(run)
}
- }
-
- package func notificationRouterMetricsForTesting() -> AppServerNotificationRouterMetrics {
- notificationRouterMetrics
+ await flushDeferredThreadCleanups()
}
package func reviewEventSessionMetricsForTesting(
threadID: String
- ) async -> AppServerReviewEventSessionMetrics? {
+ ) async -> ReviewBackendEventSessionMetrics? {
if let session = reviewEventSession(forThreadID: threadID) {
return await session.metricsSnapshot()
}
@@ -464,10 +321,6 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend {
return await session.activeStreamSubscriptionIDForTesting()
}
- package func notificationRouterIsRunningForTesting() -> Bool {
- notificationRouterTask != nil
- }
-
package func detachReviewEventStreamForTesting(threadID: String, subscriptionID: Int) async {
guard let session = reviewEventSession(forThreadID: threadID) else {
return
@@ -481,20 +334,12 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend {
}
private func reviewEventSession(for run: CodexReviewBackendModel.Review.Run) async -> AppServerReviewEventSession {
- await ensureNotificationRouterStarted()
if let session = reviewEventSessionsByAttemptID[run.attemptID] {
await session.updateRun(run)
registerReviewEventSession(session, for: run)
return session
}
- let control = controlsByThreadID[run.threadID] ?? AppServerReviewControl(client: client)
- controlsByThreadID[run.threadID] = control
- if let turnID = run.turnID {
- control.recordReviewStarted(turnThreadID: appServerTurnThreadID(for: run), turnID: turnID)
- } else {
- control.recordThreadStarted(threadID: run.threadID)
- }
- let session = AppServerReviewEventSession(run: run, control: control)
+ let session = AppServerReviewEventSession(run: run)
registerReviewEventSession(session, for: run)
return session
}
@@ -504,8 +349,9 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend {
for run: CodexReviewBackendModel.Review.Run
) {
reviewEventSessionsByAttemptID[run.attemptID] = session
- let activeThreadIDs = Set([run.threadID, run.reviewThreadID].compactMap { $0?.nilIfEmpty })
- for threadID in activeThreadIDsByAttemptID[run.attemptID] ?? [] where activeThreadIDs.contains(threadID) == false {
+ let activeThreadIDs = Set(run.appServerAssociatedThreadIDs)
+ for threadID in activeThreadIDsByAttemptID[run.attemptID] ?? []
+ where activeThreadIDs.contains(threadID) == false {
if activeReviewAttemptIDByThreadID[threadID] == run.attemptID {
activeReviewAttemptIDByThreadID.removeValue(forKey: threadID)
}
@@ -515,11 +361,10 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend {
activeReviewAttemptIDByThreadID[threadID] = run.attemptID
}
reviewEventSessionCanonicalThreadIDByThreadID[run.threadID] = run.threadID
- noteReviewThreadIDForCleanup(run.threadID, canonicalThreadID: run.threadID)
if let reviewThreadID = run.reviewThreadID,
- reviewThreadID != run.threadID {
+ reviewThreadID != run.threadID
+ {
reviewEventSessionCanonicalThreadIDByThreadID[reviewThreadID] = run.threadID
- noteReviewThreadIDForCleanup(reviewThreadID, canonicalThreadID: run.threadID)
}
}
@@ -537,47 +382,39 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend {
return reviewEventSessionsByAttemptID[attemptID]
}
- private func unregisterReviewEventSession(for run: CodexReviewBackendModel.Review.Run) -> AppServerReviewEventSession? {
- if activeReviewAttemptIDByThreadID[run.threadID] == run.attemptID {
- activeReviewAttemptIDByThreadID.removeValue(forKey: run.threadID)
- }
- if let reviewThreadID = run.reviewThreadID,
- reviewThreadID != run.threadID {
- if activeReviewAttemptIDByThreadID[reviewThreadID] == run.attemptID {
- activeReviewAttemptIDByThreadID.removeValue(forKey: reviewThreadID)
+ private func unregisterReviewEventSession(for run: CodexReviewBackendModel.Review.Run)
+ -> AppServerReviewEventSession?
+ {
+ let threadIDs =
+ activeThreadIDsByAttemptID.removeValue(forKey: run.attemptID)
+ ?? Set(run.appServerAssociatedThreadIDs)
+ for threadID in threadIDs {
+ if activeReviewAttemptIDByThreadID[threadID] == run.attemptID {
+ activeReviewAttemptIDByThreadID.removeValue(forKey: threadID)
}
}
- activeThreadIDsByAttemptID.removeValue(forKey: run.attemptID)
return reviewEventSessionsByAttemptID.removeValue(forKey: run.attemptID)
}
- private func noteReviewThreadIDForCleanup(_ threadID: String, canonicalThreadID: String) {
- reviewThreadIDsForCleanupByThreadID[canonicalThreadID, default: []].insert(threadID)
- }
-
- private func markAttemptAbandoned(
- _ run: CodexReviewBackendModel.Review.Run,
- interruption: AppServerReviewInterruption
- ) {
+ private func markAttemptAbandoned(_ run: CodexReviewBackendModel.Review.Run) {
abandonedReviewAttemptIDs.insert(run.attemptID)
- markTurnAbandoned(run.turnID)
- markTurnAbandoned(interruption.turnID)
- noteReviewThreadIDForCleanup(interruption.threadID, canonicalThreadID: run.threadID)
}
- private func markInterruptionTurnAbandoned(
- _ interruption: AppServerReviewInterruption,
- canonicalThreadID: String
- ) {
- markTurnAbandoned(interruption.turnID)
- noteReviewThreadIDForCleanup(interruption.threadID, canonicalThreadID: canonicalThreadID)
+ private func markRestartInFlight(forInterrupted run: CodexReviewBackendModel.Review.Run) {
+ inFlightRestartCountByInterruptedAttemptID[run.attemptID, default: 0] += 1
}
- private func markTurnAbandoned(_ turnID: String?) {
- guard let turnID = turnID?.nilIfEmpty else {
- return
+ private func clearRestartInFlight(forInterrupted run: CodexReviewBackendModel.Review.Run) {
+ let count = inFlightRestartCountByInterruptedAttemptID[run.attemptID, default: 0]
+ if count <= 1 {
+ inFlightRestartCountByInterruptedAttemptID.removeValue(forKey: run.attemptID)
+ } else {
+ inFlightRestartCountByInterruptedAttemptID[run.attemptID] = count - 1
}
- abandonedTurnIDs.insert(turnID)
+ }
+
+ private func isRestartInFlight(forInterrupted run: CodexReviewBackendModel.Review.Run) -> Bool {
+ inFlightRestartCountByInterruptedAttemptID[run.attemptID] != nil
}
private func activeReviewRunsForShutdown() async -> [CodexReviewBackendModel.Review.Run] {
@@ -590,473 +427,274 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend {
return Array(runsByAttemptID.values)
}
- private func bufferUnmatchedReviewNotification(_ notification: AppServerRoutedReviewNotification) -> Bool {
- guard reviewStartRequestsInFlight > 0,
- let threadID = notification.payload.threadID
- else {
- return false
- }
- notificationRouterMetrics.buffered += 1
- unmatchedReviewNotificationsByThreadID[threadID, default: []].append(notification)
- return true
- }
-
- private func takeUnmatchedReviewNotifications(for run: CodexReviewBackendModel.Review.Run) -> [AppServerRoutedReviewNotification] {
- guard let reviewThreadID = run.reviewThreadID else {
- return []
- }
- let notifications = unmatchedReviewNotificationsByThreadID.removeValue(forKey: reviewThreadID) ?? []
- notificationRouterMetrics.routed += notifications.count
- return notifications
- }
-
- private func discardUnmatchedReviewNotificationsIfIdle() {
- guard reviewStartRequestsInFlight == 0 else {
- return
- }
- unmatchedReviewNotificationsByThreadID.removeAll(keepingCapacity: true)
- }
-
private func finishReviewEventStream(
threadID: String,
- cancellationMessage: String?,
- buffersMissingContinuation: Bool = false
+ cancellationMessage: String?
) async {
guard let session = reviewEventSession(forThreadID: threadID) else {
return
}
- await session.finish(
- cancellationMessage: cancellationMessage,
- buffersMissingContinuation: buffersMissingContinuation
- )
- }
-
- private func sendTurnInterrupt(
- for run: CodexReviewBackendModel.Review.Run,
- willInterruptActiveTurn: (@Sendable (AppServerReviewInterruption) async -> Void)? = nil
- ) async throws -> AppServerReviewInterruption {
- if let control = controlsByThreadID[run.threadID],
- let interruption = try await control.interrupt(willInterruptActiveTurn: willInterruptActiveTurn) {
- return interruption
- }
- let threadID = appServerTurnThreadID(for: run)
- let _: EmptyResponse = try await client.send(AppServerAPI.Turn.Interrupt.Request(
- params: .init(threadID: threadID, turnID: run.turnID ?? "")
- ))
- return .init(threadID: threadID, turnID: run.turnID ?? "")
+ await session.finish(cancellationMessage: cancellationMessage)
}
- private func cleanupThreadIDs(for run: CodexReviewBackendModel.Review.Run) -> [String] {
- var seen: Set = []
- var threadIDs: [String] = []
- let registeredReviewThreadIDs = (reviewThreadIDsForCleanupByThreadID[run.threadID] ?? [])
- .filter { $0 != run.threadID }
- .sorted()
- for threadID in registeredReviewThreadIDs where seen.insert(threadID).inserted {
- threadIDs.append(threadID)
- }
- if let reviewThreadID = run.reviewThreadID,
- reviewThreadID != run.threadID,
- seen.insert(reviewThreadID).inserted {
- threadIDs.append(reviewThreadID)
+ private func cancelReviewTurn(
+ for run: CodexReviewBackendModel.Review.Run
+ ) async throws -> CodexTurnCancellation {
+ guard let identity = run.appServerReviewIdentity else {
+ throw CodexReviewAPI.Error.io("Review run has no cancellable app-server turn.")
}
- if seen.insert(run.threadID).inserted {
- threadIDs.append(run.threadID)
+ if let session = reviewEventSessionsByAttemptID[run.attemptID],
+ let cancellation = try await session.cancelReview(
+ expectedTurnID: identity.turnID.rawValue
+ )
+ {
+ return cancellation
}
- return threadIDs
+ let review = try await appServer.resumeReview(
+ identity,
+ threadOptions: reviewThreadOptions(model: run.model)
+ )
+ return try await review.cancel()
}
- private func mergedCleanupThreadIDs(_ lhs: [String], _ rhs: [String]) -> [String] {
+ private func localCleanupThreadIDs(
+ for run: CodexReviewBackendModel.Review.Run,
+ additional: [String]
+ ) -> [String] {
+ let sourceThreadID = run.threadID
var seen: Set = []
- var merged: [String] = []
- for threadID in lhs + rhs where seen.insert(threadID).inserted {
- merged.append(threadID)
- }
- return merged
- }
-
- private func ensureNotificationRouterStarted() async {
- if notificationRouterTask != nil {
- return
- }
- while isNotificationRouterStarting {
- await Task.yield()
- if notificationRouterTask != nil {
- return
+ var threadIDs: [String] = []
+ for sequence in [run.appServerCleanupThreadIDs, additional] {
+ for threadID in sequence where threadID != sourceThreadID && seen.insert(threadID).inserted {
+ threadIDs.append(threadID)
}
}
- isNotificationRouterStarting = true
- let notifications = await client.notificationStream()
- notificationRouterTask = Task { [notifications] in
- await self.consumeReviewNotifications(notifications)
+ if seen.insert(sourceThreadID).inserted {
+ threadIDs.append(sourceThreadID)
}
- isNotificationRouterStarting = false
+ return threadIDs
}
- private func consumeReviewNotifications(
- _ notifications: AsyncThrowingStream
+ private func cleanupAppServerReview(
+ _ run: CodexReviewBackendModel.Review.Run,
+ additionalCleanupThreadIDs: [String]
) async {
- do {
- for try await notification in notifications {
- await routeReviewNotification(notification)
+ guard let identity = run.appServerReviewIdentity else {
+ for threadID in localCleanupThreadIDs(for: run, additional: additionalCleanupThreadIDs) {
+ try? await appServer.deleteThread(.init(rawValue: threadID))
}
- await finishAllReviewEventSessions(throwing: nil)
- } catch {
- await finishAllReviewEventSessions(throwing: error)
- }
- notificationRouterTask = nil
- }
-
- private func routeReviewNotification(_ notification: JSONRPC.Notification) async {
- notificationRouterMetrics.received += 1
- guard isReviewNotificationMethod(notification.method) else {
- notificationRouterMetrics.ignored += 1
return
}
- guard let payload = try? JSONDecoder().decode(TurnNotificationPayload.self, from: notification.params) else {
- notificationRouterMetrics.ignored += 1
- return
- }
- notificationRouterMetrics.decoded += 1
- if let turnID = payload.resolvedTurnID,
- abandonedTurnIDs.contains(turnID) {
- notificationRouterMetrics.ignored += 1
- return
- }
-
- reviewNotificationSequence += 1
- let routed = AppServerRoutedReviewNotification(
- sequence: reviewNotificationSequence,
- method: notification.method,
- params: notification.params,
- payload: payload
+ await appServer.cleanupReview(
+ identity,
+ additionalCleanupThreadIDs: [additionalCleanupThreadIDs.map(CodexThreadID.init(rawValue:))]
)
- if let threadID = payload.threadID {
- guard let session = reviewEventSession(forThreadID: threadID) else {
- if bufferUnmatchedReviewNotification(routed) {
- return
- }
- notificationRouterMetrics.ignored += 1
- return
- }
- notificationRouterMetrics.routed += 1
- await session.receive(routed)
- } else if isThreadlessBroadcastMethod(notification.method) {
- let sessions = Array(reviewEventSessionsByAttemptID.values)
- guard sessions.isEmpty == false else {
- notificationRouterMetrics.ignored += 1
- return
- }
- notificationRouterMetrics.routed += sessions.count
- for session in sessions {
- await session.receive(routed)
- }
- } else {
- notificationRouterMetrics.ignored += 1
- }
- }
-
- private func finishAllReviewEventSessions(throwing error: (any Error)?) async {
- let sessions = Array(reviewEventSessionsByAttemptID.values)
- for session in sessions {
- await session.finish(throwing: error)
- }
- }
-
- private func readModelCatalog() async throws -> [CodexReviewSettings.ModelCatalogItem] {
- var cursor: String?
- var models: [CodexReviewSettings.ModelCatalogItem] = []
- repeat {
- let response = try await client.send(AppServerAPI.Model.List.Request(
- params: .init(cursor: cursor, includeHidden: true)
- ))
- models.append(contentsOf: response.data)
- cursor = response.nextCursor?.nilIfEmpty
- } while cursor != nil
- return models
}
-}
-
-package struct AppServerNotificationRouterMetrics: Equatable, Sendable {
- package var received = 0
- package var decoded = 0
- package var routed = 0
- package var ignored = 0
- package var buffered = 0
-
- package init() {}
-}
-
-package struct AppServerReviewEventSessionMetrics: Equatable, Sendable {
- package var routed = 0
- package var decoded = 0
- package var emitted = 0
- package var ignored = 0
- package var buffered = 0
- package var commandTimeoutWarnings = 0
- package var firstEventLatencyMs: Int?
- package var terminalLatencyMs: Int?
-
- package init() {}
-}
-private struct AppServerRoutedReviewNotification: Sendable {
- var sequence: Int
- var method: String
- var params: Data
- var payload: TurnNotificationPayload
}
-private struct DecodedReviewNotification {
+private struct AppServerTypedReviewEvent: Sendable {
var events: [CodexReviewBackendModel.Review.Event]
- var turnID: String?
- var startsReviewMode: Bool
- var finishesReviewMode: Bool
- var hasDirectTimelineEvents: Bool
+ var controlThreadID: String?
- var reviewExitResult: String? {
- guard finishesReviewMode else {
- return nil
- }
- var result: String?
- for event in events {
- guard case .logEntry(.agentMessage, let text, _, _, _) = event,
- let text = text.nilIfEmpty
- else {
- continue
- }
- result = text
- }
- return result
+ init(
+ events: [CodexReviewBackendModel.Review.Event],
+ controlThreadID: String? = nil
+ ) {
+ self.events = events
+ self.controlThreadID = controlThreadID
}
+}
+private enum AppServerTypedItemPhase {
+ case started
+ case updated
+ case completed
}
-private struct PendingStreamedLogEntry: Sendable {
- struct Key: Hashable, Sendable {
- var kind: ReviewLogEntry.Kind
- var groupID: String
- var sourceType: String?
- var itemID: String?
+private enum AppServerTypedReviewEventAdapter {
+ static func started(
+ review: CodexReviewSession,
+ run: CodexReviewBackendModel.Review.Run
+ ) -> AppServerTypedReviewEvent {
+ .init(
+ events: [
+ .started(
+ turnID: review.turnID.rawValue,
+ reviewThreadID: review.reviewThreadID.rawValue,
+ model: run.model
+ )
+ ],
+ controlThreadID: review.reviewThreadID.rawValue
+ )
+ }
+
+ static func convert(
+ _ event: CodexReviewEvent,
+ review: CodexReviewSession
+ ) -> AppServerTypedReviewEvent {
+ let controlThreadID = review.reviewThreadID.rawValue
+ return switch event {
+ case .turnStarted:
+ .init(events: [], controlThreadID: controlThreadID)
+ case .turnCompleted(let response):
+ .init(events: terminalEvents(for: response), controlThreadID: controlThreadID)
+ case .turnFailed(_, let message):
+ .init(events: [.failed(message.nilIfEmpty ?? "Failed.")], controlThreadID: controlThreadID)
+ case .itemStarted(let item, _):
+ .init(events: itemEvents(item, phase: .started), controlThreadID: controlThreadID)
+ case .itemUpdated(let item, _):
+ .init(events: itemEvents(item, phase: .updated), controlThreadID: controlThreadID)
+ case .itemCompleted(let item, _):
+ .init(events: itemEvents(item, phase: .completed), controlThreadID: controlThreadID)
+ case .message,
+ .messageDelta:
+ .init(events: [], controlThreadID: controlThreadID)
+ case .reasoningSummaryPartAdded:
+ .init(events: [], controlThreadID: controlThreadID)
+ case .reasoningDelta:
+ .init(events: [], controlThreadID: controlThreadID)
+ case .tokenUsageUpdated:
+ .init(events: [], controlThreadID: controlThreadID)
+ case .statusChanged(.idle), .statusChanged(.active(activeFlags: _)):
+ .init(events: [], controlThreadID: controlThreadID)
+ case .statusChanged(.notLoaded):
+ .init(events: [.failed("Review thread is no longer loaded.")], controlThreadID: controlThreadID)
+ case .statusChanged(.systemError):
+ .init(events: [.failed("Review thread has a system error.")], controlThreadID: controlThreadID)
+ case .statusChanged(.unknown(let status)):
+ .init(
+ events: unknownStatusEvents(status, turnID: review.turnID.rawValue),
+ controlThreadID: controlThreadID
+ )
+ case .closed:
+ .init(events: [.failed("Review thread closed.")], controlThreadID: controlThreadID)
+ case .unknown(let raw):
+ .init(events: unknownEvents(raw), controlThreadID: controlThreadID)
+ }
}
- var kind: ReviewLogEntry.Kind
- var text: String
- var groupID: String
- var metadata: ReviewLogEntry.Metadata?
- var suppressesTimelineProjection: Bool
-
- var key: Key {
- .init(
- kind: kind,
- groupID: groupID,
- sourceType: metadata?.sourceType,
- itemID: metadata?.itemID
- )
+ private static func terminalEvents(
+ for response: CodexResponse
+ ) -> [CodexReviewBackendModel.Review.Event] {
+ if let message = response.errorMessage?.nilIfEmpty {
+ return terminalFailureEvents(status: response.status, message: message)
+ }
+ if response.status?.isFailure == true {
+ return terminalFailureEvents(
+ status: response.status,
+ message: response.status?.rawValue ?? "Failed."
+ )
+ }
+ guard let finalReview = response.finalAnswer?.nilIfEmpty
+ ?? response.transcript.finalAnswer?.nilIfEmpty
+ else {
+ return [.failed("Review completed without a final response.")]
+ }
+ return [
+ .completed(finalReview: finalReview)
+ ]
}
- var events: [CodexReviewBackendModel.Review.Event] {
- let logEntry = CodexReviewBackendModel.Review.Event.logEntry(
- kind: kind,
- text: text,
- groupID: groupID,
- replacesGroup: false,
- metadata: metadata
- )
- return suppressesTimelineProjection
- ? [.suppressNextLegacyTimelineProjection, logEntry]
- : [logEntry]
+ private static func unknownStatusEvents(
+ _: String,
+ turnID _: String
+ ) -> [CodexReviewBackendModel.Review.Event] {
+ []
}
- init?(_ event: CodexReviewBackendModel.Review.Event, suppressesTimelineProjection: Bool = false) {
- guard case .logEntry(let kind, let text, let groupID, let replacesGroup, let metadata) = event,
- text.isEmpty == false,
- replacesGroup == false,
- let groupID
- else {
- return nil
- }
- switch kind {
- case .commandOutput:
- guard metadata?.sourceType == "commandExecution",
- metadata?.title == "Command output"
- else {
- return nil
- }
- case .reasoningSummary, .rawReasoning:
- break
- case .agentMessage, .command, .plan, .reasoning, .todoList, .toolCall, .diagnostic, .error, .progress, .event, .contextCompaction:
- return nil
+ private static func terminalFailureEvents(
+ status: CodexTurnStatus?,
+ message: String
+ ) -> [CodexReviewBackendModel.Review.Event] {
+ switch status {
+ case .interrupted, .cancelled:
+ [.cancelled(message)]
+ case .failed, .running, .completed, .unknown, nil:
+ [.failed(message)]
}
- self.kind = kind
- self.text = text
- self.groupID = groupID
- self.metadata = metadata
- self.suppressesTimelineProjection = suppressesTimelineProjection
}
- mutating func append(_ suffix: String) {
- text += suffix
+ private static func itemEvents(
+ _ item: CodexThreadItem,
+ phase: AppServerTypedItemPhase
+ ) -> [CodexReviewBackendModel.Review.Event] {
+ guard item.kind.rawValue != "enteredReviewMode" else {
+ return []
+ }
+ return []
}
- mutating func suppressTimelineProjection() {
- suppressesTimelineProjection = true
+ private static func unknownEvents(
+ _: CodexRawNotification
+ ) -> [CodexReviewBackendModel.Review.Event] {
+ []
}
}
private actor AppServerReviewEventSession {
- private static let commandTimeoutExitCode = 124
- private static let longCommandDurationWarningMs = 100_000
- private static let streamedLogFlushIntervalNanoseconds: UInt64 = 20_000_000
-
- private var run: CodexReviewBackendModel.Review.Run
- private let control: AppServerReviewControl
- private let mailbox: BackendReviewEventMailbox
- private var trackedTurnIDs: Set
- private var emittedStartedTurnIDs: Set = []
- private var reviewThreadIDsForCleanup: [String] = []
- private var commandLifecycleByItemID: [String: AppServerCommandLifecycle] = [:]
- private var pendingStreamedLogEntries: [PendingStreamedLogEntry] = []
- private var pendingStreamedLogIndexByKey: [PendingStreamedLogEntry.Key: Int] = [:]
- private var streamedLogFlushTask: Task?
- private var awaitingReviewExit = false
- private var cancellationRequestedMessage: String?
- private let completionCoordinator = ReviewCompletionCoordinator()
- private let createdAt = Date()
- private var finished = false
- private var isRunFinalized: Bool
- private var isDrainingStartupNotifications = false
- private var pendingStartupNotifications: [AppServerRoutedReviewNotification] = []
- private var metrics = AppServerReviewEventSessionMetrics()
+ private let pipeline: ReviewBackendEventSession
+ private var typedReviewStreamTask: Task?
+ private var reviewSession: CodexReviewSession?
init(
run: CodexReviewBackendModel.Review.Run,
- control: AppServerReviewControl,
- mailbox: BackendReviewEventMailbox = .init(),
- isRunFinalized: Bool = true
+ mailbox: BackendReviewEventMailbox = .init()
) {
- self.run = run
- self.control = control
- self.mailbox = mailbox
- self.isRunFinalized = isRunFinalized
- self.trackedTurnIDs = Set(run.turnID.map { [$0] } ?? [])
- if let reviewThreadID = run.reviewThreadID?.nilIfEmpty,
- reviewThreadID != run.threadID {
- self.reviewThreadIDsForCleanup.append(reviewThreadID)
- }
- }
-
- func updateRun(_ run: CodexReviewBackendModel.Review.Run) {
- self.run = run
- if let turnID = run.turnID {
- trackedTurnIDs.insert(turnID)
- }
- noteReviewThreadIDForCleanup(run.reviewThreadID)
- }
-
- func bufferStartupNotifications(_ notifications: [AppServerRoutedReviewNotification]) {
- guard notifications.isEmpty == false else {
- return
- }
- metrics.routed += notifications.count
- metrics.buffered += notifications.count
- pendingStartupNotifications.append(contentsOf: notifications)
- }
-
- func finalizeRun() async {
- guard isRunFinalized == false else {
- return
- }
- isRunFinalized = true
- pendingStartupNotifications.sort { $0.sequence < $1.sequence }
- await drainStartupNotifications()
+ self.pipeline = ReviewBackendEventSession(
+ run: run,
+ mailbox: mailbox,
+ callbacks: .init(
+ recordFinished: { run, metrics in
+ appServerBackendLogger.debug(
+ "Review event session finished for \(run.threadID, privacy: .public): emitted=\(metrics.emitted, privacy: .public) buffered=\(metrics.buffered, privacy: .public) ignored=\(metrics.ignored, privacy: .public) timeoutWarnings=\(metrics.commandTimeoutWarnings, privacy: .public)"
+ )
+ }
+ )
+ )
}
- func currentRun() -> CodexReviewBackendModel.Review.Run {
- run
+ func updateRun(_ run: CodexReviewBackendModel.Review.Run) async {
+ await pipeline.updateRun(run)
}
- func attempt() -> BackendReviewAttempt {
- .init(run: run, events: mailbox)
+ func currentRun() async -> CodexReviewBackendModel.Review.Run {
+ await pipeline.currentRun()
}
- func cleanupThreadIDs() -> [String] {
- var threadIDs = reviewThreadIDsForCleanup.filter { $0 != run.threadID }
- threadIDs.append(run.threadID)
- return threadIDs
+ func attempt() async -> BackendReviewAttempt {
+ await pipeline.attempt()
}
- func requestCancellation(message: String) {
- cancellationRequestedMessage = message
+ func cleanupThreadIDs() async -> [String] {
+ await pipeline.cleanupThreadIDs()
}
- func clearCancellationRequest() {
- cancellationRequestedMessage = nil
+ func requestCancellation(message: String) async {
+ await pipeline.requestCancellation(message: message)
}
- func receive(_ notification: AppServerRoutedReviewNotification) async {
- metrics.routed += 1
- guard finished == false else {
- metrics.ignored += 1
- return
- }
- guard isRunFinalized, isDrainingStartupNotifications == false else {
- metrics.buffered += 1
- pendingStartupNotifications.append(notification)
- return
- }
- await process(notification)
+ func clearCancellationRequest() async {
+ await pipeline.clearCancellationRequest()
}
- func finish(
- cancellationMessage: String?,
- buffersMissingContinuation _: Bool = false
- ) async {
- var precedingEvents = drainPendingStreamedLogEvents()
- if cancellationMessage == nil {
- cancelPendingStreamedLogFlush()
- } else {
- cancellationRequestedMessage = cancellationMessage
- precedingEvents.append(contentsOf: commandLifecycleByItemID.closeActiveCommands(status: "canceled"))
- commandLifecycleByItemID.removeAll(keepingCapacity: true)
- }
- await finish(precedingEvents: precedingEvents, cancellationMessage: cancellationMessage)
+ func finish(cancellationMessage: String?) async {
+ cancelTypedReviewStream()
+ await pipeline.finish(cancellationMessage: cancellationMessage)
}
func finish(throwing error: (any Error)?) async {
- guard finished == false else {
- return
- }
- let precedingEvents = drainPendingStreamedLogEvents()
- finished = true
- completionCoordinator.cancelPendingCompletion()
- cancelPendingStreamedLogFlush()
- commandLifecycleByItemID.removeAll(keepingCapacity: true)
- pendingStartupNotifications.removeAll(keepingCapacity: true)
- await emitPrecedingEvents(precedingEvents)
- if let error {
- await mailbox.fail(error)
- } else {
- await mailbox.finish()
- }
+ cancelTypedReviewStream()
+ await pipeline.finish(throwing: error)
}
func abandon() async {
- guard finished == false else {
- return
- }
- finished = true
- completionCoordinator.cancelPendingCompletion()
- cancelPendingStreamedLogFlush()
- commandLifecycleByItemID.removeAll(keepingCapacity: true)
- pendingStreamedLogEntries.removeAll(keepingCapacity: true)
- pendingStreamedLogIndexByKey.removeAll(keepingCapacity: true)
- pendingStartupNotifications.removeAll(keepingCapacity: true)
- await mailbox.abandon()
+ cancelTypedReviewStream()
+ await pipeline.abandon()
}
- func metricsSnapshot() -> AppServerReviewEventSessionMetrics {
- metrics
+ func metricsSnapshot() async -> ReviewBackendEventSessionMetrics {
+ await pipeline.metricsSnapshot()
}
func activeStreamSubscriptionIDForTesting() -> Int? {
@@ -1065,2287 +703,150 @@ private actor AppServerReviewEventSession {
func detach(subscriptionID _: Int) {}
- private func finish(
- precedingEvents: [CodexReviewBackendModel.Review.Event],
- cancellationMessage: String?
- ) async {
- guard finished == false else {
+ func startConsuming(_ review: CodexReviewSession) {
+ guard typedReviewStreamTask == nil else {
return
}
- completionCoordinator.cancelPendingCompletion()
- cancelPendingStreamedLogFlush()
- pendingStartupNotifications.removeAll(keepingCapacity: true)
- await emitPrecedingEvents(precedingEvents)
- if let cancellationMessage {
- _ = await emit(.cancelled(cancellationMessage))
- } else {
- await mailbox.finish()
+ reviewSession = review
+ typedReviewStreamTask = Task { [weak self] in
+ await self?.consume(review)
}
- finished = true
}
- private func drainStartupNotifications() async {
- guard isDrainingStartupNotifications == false else {
- return
- }
- isDrainingStartupNotifications = true
- defer {
- isDrainingStartupNotifications = false
- }
- while finished == false, pendingStartupNotifications.isEmpty == false {
- let notification = pendingStartupNotifications.removeFirst()
- await process(notification)
+ func cancelReview(
+ expectedTurnID _: String
+ ) async throws -> CodexTurnCancellation? {
+ guard let reviewSession else {
+ return nil
}
+ return try await reviewSession.cancel()
}
- private func process(_ notification: AppServerRoutedReviewNotification) async {
- var decodedCommandLifecycleByItemID = commandLifecycleByItemID
- guard let decoded = try? decodeReviewNotification(
- notification,
- fallbackReviewThreadID: run.reviewThreadID ?? run.threadID,
- commandLifecycleByItemID: &decodedCommandLifecycleByItemID
- ) else {
- metrics.ignored += 1
- return
+ private func consume(_ review: CodexReviewSession) async {
+ defer {
+ typedReviewStreamTask = nil
+ if reviewSession?.id == review.id {
+ reviewSession = nil
+ }
}
- metrics.decoded += 1
- let controlThreadID = notification.payload.threadID
- guard decoded.events.isEmpty == false else {
- metrics.ignored += 1
- return
- }
- if decoded.startsReviewMode {
- awaitingReviewExit = true
- }
-
- let shouldEmitNotification: Bool
- if decoded.events.count == 1,
- case .started(let turnID, let reviewThreadID, _) = decoded.events[0]
- {
- let matchesDetachedReviewThread = run.reviewThreadID != nil
- && run.reviewThreadID != run.threadID
- && reviewThreadID == run.reviewThreadID
- guard trackedTurnIDs.isEmpty
- || trackedTurnIDs.contains(turnID)
- || matchesDetachedReviewThread
- || notification.payload.threadID == run.threadID
- else {
- metrics.ignored += 1
- return
- }
- trackedTurnIDs.insert(turnID)
- shouldEmitNotification = emittedStartedTurnIDs.insert(turnID).inserted
- } else if let turnID = decoded.turnID {
- if trackedTurnIDs.contains(turnID) == false {
- let preservesReviewModeCompletion = awaitingReviewExit
- && decoded.finishesReviewMode
- if decoded.startsReviewMode || trackedTurnIDs.isEmpty {
- trackedTurnIDs.insert(turnID)
- } else if preservesReviewModeCompletion {
- trackedTurnIDs.insert(turnID)
- } else {
- metrics.ignored += 1
- return
- }
- }
- if decoded.events.contains(where: \.triggersSyntheticStartedTurn),
- emittedStartedTurnIDs.contains(turnID) == false
- {
- emittedStartedTurnIDs.insert(turnID)
- let started = CodexReviewBackendModel.Review.Event.started(
- turnID: turnID,
- reviewThreadID: run.reviewThreadID ?? run.threadID,
- model: nil
- )
- if await emit(started, controlThreadID: controlThreadID) {
- return
- }
- }
- shouldEmitNotification = true
- } else {
- shouldEmitNotification = true
- }
-
- guard shouldEmitNotification else {
- metrics.ignored += 1
- return
- }
- if shouldCloseActiveCommandsBeforeEvents(
- notification: notification,
- decoded: decoded
- ) {
- if await flushPendingStreamedLog(controlThreadID: controlThreadID) {
- return
- }
- let closedItemIDs = Set(commandLifecycleByItemID.keys)
- if await closeActiveCommandsForProgressBoundary(
- controlThreadID: controlThreadID
- ) {
- return
- }
- for itemID in closedItemIDs {
- decodedCommandLifecycleByItemID.removeValue(forKey: itemID)
- }
- }
- commandLifecycleByItemID = decodedCommandLifecycleByItemID
-
- if decoded.finishesReviewMode {
- if await flushPendingStreamedLog(controlThreadID: controlThreadID) {
- return
- }
- if await closeActiveCommandsForReviewExit(
- controlThreadID: controlThreadID
- ) {
- return
- }
- }
-
- for index in decoded.events.indices {
- let event = decoded.events[index]
- if case .domainEvents = event {
- let followingEvents = decoded.events[decoded.events.index(after: index)...]
- if shouldFlushPendingStreamedLogBeforeDomainEvent(
- followingEvents: followingEvents,
- suppressTimelineProjection: decoded.hasDirectTimelineEvents
- ),
- await flushPendingStreamedLog(controlThreadID: controlThreadID) {
- return
- }
- if await emit(event, controlThreadID: controlThreadID) {
- return
- }
- continue
- }
- if bufferStreamedLog(
- event,
- suppressTimelineProjection: decoded.hasDirectTimelineEvents
- ) {
- continue
- }
- if await flushPendingStreamedLog(controlThreadID: controlThreadID) {
- return
- }
- for commandEvent in commandLifecycleByItemID.closeActiveCommands(for: event) {
- if await emit(commandEvent, controlThreadID: controlThreadID) {
- return
- }
- }
- if event.activeCommandTerminalStatus != nil {
- commandLifecycleByItemID.removeAll(keepingCapacity: true)
- }
- if event.shouldDeferCompletion(awaitingReviewExit: awaitingReviewExit) {
- completionCoordinator.deferCompletion(event)
- continue
- }
- if await emit(event, controlThreadID: controlThreadID) {
- return
- }
- }
-
- if decoded.finishesReviewMode {
- if await flushPendingStreamedLog(controlThreadID: controlThreadID) {
- return
- }
- awaitingReviewExit = false
- if let cancellationRequestedMessage {
- if await emit(
- .cancelled(cancellationRequestedMessage),
- controlThreadID: controlThreadID
- ) {
- return
- }
- }
- if let reviewExitResult = decoded.reviewExitResult {
- completionCoordinator.cancelPendingCompletion()
- if await emit(
- .completed(summary: "Succeeded.", result: reviewExitResult),
- controlThreadID: controlThreadID
- ) {
+ let run = await pipeline.currentRun()
+ await receive(AppServerTypedReviewEventAdapter.started(review: review, run: run))
+ do {
+ for try await event in review.events {
+ if Task.isCancelled {
return
}
- } else if await flushPendingCompletion(controlThreadID: controlThreadID) {
- return
- } else if await emit(
- .completed(summary: "Succeeded.", result: nil),
- controlThreadID: controlThreadID
- ) {
- return
- }
- }
- }
-
- private func noteReviewThreadIDForCleanup(_ reviewThreadID: String?) {
- guard let reviewThreadID = reviewThreadID?.nilIfEmpty,
- reviewThreadID != run.threadID,
- reviewThreadIDsForCleanup.contains(reviewThreadID) == false
- else {
- return
- }
- reviewThreadIDsForCleanup.append(reviewThreadID)
- }
-
- private func emit(
- _ event: CodexReviewBackendModel.Review.Event,
- controlThreadID: String? = nil
- ) async -> Bool {
- noteEmission(event)
- let didFinish = completionCoordinator.emit(event)
- await mailbox.append(event)
- recordReviewEvent(event, controlThreadID: controlThreadID)
- return didFinish
- }
-
- private func shouldCloseActiveCommandsBeforeEvents(
- notification: AppServerRoutedReviewNotification,
- decoded: DecodedReviewNotification
- ) -> Bool {
- guard commandLifecycleByItemID.isEmpty == false else {
- return false
- }
- let startsNewCommand = notification.method == "item/started"
- && notification.payload.item?.type == "commandExecution"
- let reachesModelProgress = decoded.events.contains(where: Self.isCommandProgressBoundary(_:))
- guard startsNewCommand || reachesModelProgress else {
- return false
- }
-
- switch notification.method {
- case "item/commandExecution/outputDelta",
- "command/exec/outputDelta",
- "process/outputDelta",
- "item/commandExecution/terminalInteraction":
- return false
- case "item/completed" where notification.payload.item?.type == "commandExecution":
- return false
- case "turn/completed", "turn/failed", "turn/cancelled", "thread/closed":
- return false
- default:
- return true
- }
- }
-
- private static func isCommandProgressBoundary(_ event: CodexReviewBackendModel.Review.Event) -> Bool {
- switch event {
- case .started, .message, .messageDelta, .log:
- return true
- case .domainEvents(let events, _):
- return events.contains(where: \.isCommandProgressBoundary)
- case .suppressNextLegacyTimelineProjection,
- .suppressNextTerminalFailureLogTimelineProjection:
- return false
- case .logEntry(let kind, _, _, _, _):
- return kind != .command && kind != .commandOutput
- case .completed, .failed, .cancelled:
- return false
- }
- }
-
- private func closeActiveCommandsForProgressBoundary(
- controlThreadID: String? = nil
- ) async -> Bool {
- guard commandLifecycleByItemID.isEmpty == false else {
- return false
- }
- let status = cancellationRequestedMessage == nil ? "completed" : "canceled"
- for commandEvent in commandLifecycleByItemID.closeActiveCommands(status: status) {
- if await emit(commandEvent, controlThreadID: controlThreadID) {
- return true
- }
- }
- commandLifecycleByItemID.removeAll(keepingCapacity: true)
- return false
- }
-
- private func closeActiveCommandsForReviewExit(
- controlThreadID: String? = nil
- ) async -> Bool {
- guard commandLifecycleByItemID.isEmpty == false else {
- return false
- }
- let status = cancellationRequestedMessage == nil ? "completed" : "canceled"
- appServerBackendLogger.info(
- "Review mode exited with \(self.commandLifecycleByItemID.count, privacy: .public) active command execution(s); closing as \(status, privacy: .public)."
- )
- for commandEvent in commandLifecycleByItemID.closeActiveCommands(status: status) {
- if await emit(commandEvent, controlThreadID: controlThreadID) {
- return true
- }
- }
- commandLifecycleByItemID.removeAll(keepingCapacity: true)
- return false
- }
-
- private func shouldFlushPendingStreamedLogBeforeDomainEvent(
- followingEvents: ArraySlice,
- suppressTimelineProjection: Bool
- ) -> Bool {
- guard pendingStreamedLogEntries.isEmpty == false else {
- return false
- }
- return followingEvents.contains {
- canCoalescePendingStreamedLog(
- with: $0,
- suppressTimelineProjection: suppressTimelineProjection
- )
- } == false
- }
-
- private func canCoalescePendingStreamedLog(
- with event: CodexReviewBackendModel.Review.Event,
- suppressTimelineProjection: Bool
- ) -> Bool {
- guard let entry = PendingStreamedLogEntry(
- event,
- suppressesTimelineProjection: suppressTimelineProjection
- ) else {
- return false
- }
- return pendingStreamedLogIndexByKey[entry.key] != nil
- }
-
- private func bufferStreamedLog(
- _ event: CodexReviewBackendModel.Review.Event,
- suppressTimelineProjection: Bool = false
- ) -> Bool {
- guard let entry = PendingStreamedLogEntry(
- event,
- suppressesTimelineProjection: suppressTimelineProjection
- ) else {
- return false
- }
- if let index = pendingStreamedLogIndexByKey[entry.key] {
- pendingStreamedLogEntries[index].append(entry.text)
- if suppressTimelineProjection {
- pendingStreamedLogEntries[index].suppressTimelineProjection()
- }
- } else {
- pendingStreamedLogIndexByKey[entry.key] = pendingStreamedLogEntries.count
- pendingStreamedLogEntries.append(entry)
- }
- schedulePendingStreamedLogFlush()
- return true
- }
-
- private func schedulePendingStreamedLogFlush() {
- guard streamedLogFlushTask == nil else {
- return
- }
- streamedLogFlushTask = Task { [weak self] in
- do {
- try await Task.sleep(nanoseconds: Self.streamedLogFlushIntervalNanoseconds)
- } catch {
- return
- }
- await self?.flushPendingStreamedLogFromTimer()
- }
- }
-
- private func flushPendingStreamedLogFromTimer() async {
- streamedLogFlushTask = nil
- _ = await flushPendingStreamedLog()
- }
-
- private func flushPendingStreamedLog(
- controlThreadID: String? = nil
- ) async -> Bool {
- let events = drainPendingStreamedLogEvents()
- guard events.isEmpty == false else {
- return false
- }
- cancelPendingStreamedLogFlush()
- for event in events {
- if await emit(event, controlThreadID: controlThreadID) {
- return true
- }
- }
- return false
- }
-
- private func drainPendingStreamedLogEvents() -> [CodexReviewBackendModel.Review.Event] {
- let events = pendingStreamedLogEntries.flatMap(\.events)
- pendingStreamedLogEntries.removeAll(keepingCapacity: true)
- pendingStreamedLogIndexByKey.removeAll(keepingCapacity: true)
- return events
- }
-
- private func cancelPendingStreamedLogFlush() {
- streamedLogFlushTask?.cancel()
- streamedLogFlushTask = nil
- }
-
- private func flushPendingCompletion(
- controlThreadID: String? = nil
- ) async -> Bool {
- guard let event = completionCoordinator.flushPendingCompletion() else {
- return false
- }
- noteEmission(event)
- await mailbox.append(event)
- recordReviewEvent(event, controlThreadID: controlThreadID)
- return true
- }
-
- private func recordReviewEvent(_ event: CodexReviewBackendModel.Review.Event, controlThreadID: String? = nil) {
- switch event {
- case .started(let turnID, _, _):
- control.recordTurnStarted(turnThreadID: controlThreadID ?? appServerTurnThreadID(for: run), turnID: turnID)
- case .completed, .failed, .cancelled:
- control.finish()
- appServerBackendLogger.debug(
- "Review event session finished for \(self.run.threadID, privacy: .public): emitted=\(self.metrics.emitted, privacy: .public) buffered=\(self.metrics.buffered, privacy: .public) ignored=\(self.metrics.ignored, privacy: .public) timeoutWarnings=\(self.metrics.commandTimeoutWarnings, privacy: .public)"
- )
- case .domainEvents,
- .suppressNextLegacyTimelineProjection,
- .suppressNextTerminalFailureLogTimelineProjection,
- .message,
- .messageDelta,
- .log,
- .logEntry:
- break
- }
- }
-
- private func noteEmissions(_ events: [CodexReviewBackendModel.Review.Event]) {
- for event in events {
- noteEmission(event)
- }
- }
-
- private func emitPrecedingEvents(_ events: [CodexReviewBackendModel.Review.Event]) async {
- noteEmissions(events)
- for event in events {
- await mailbox.append(event)
- recordReviewEvent(event)
- }
- }
-
- private func noteEmission(_ event: CodexReviewBackendModel.Review.Event) {
- metrics.emitted += 1
- if metrics.firstEventLatencyMs == nil {
- metrics.firstEventLatencyMs = Self.durationMs(from: createdAt, to: Date())
- }
- if event.isTerminal {
- metrics.terminalLatencyMs = Self.durationMs(from: createdAt, to: Date())
- }
- if Self.isCommandTimeoutWarning(event) {
- metrics.commandTimeoutWarnings += 1
- }
- }
-
- private static func isCommandTimeoutWarning(_ event: CodexReviewBackendModel.Review.Event) -> Bool {
- guard case .logEntry(_, _, _, _, let metadata) = event,
- metadata?.sourceType == "commandExecution"
- else {
- return false
- }
- if metadata?.exitCode == commandTimeoutExitCode {
- return true
- }
- return (metadata?.durationMs ?? 0) >= longCommandDurationWarningMs
- }
-
- private static func durationMs(from start: Date, to end: Date) -> Int {
- let milliseconds = end.timeIntervalSince(start) * 1000
- guard milliseconds.isFinite else {
- return 0
- }
- return max(0, Int(milliseconds.rounded()))
- }
-}
-
-private extension CodexReviewBackendModel.Review.Event {
- var isTerminal: Bool {
- return switch self {
- case .completed, .failed, .cancelled:
- true
- case .domainEvents,
- .suppressNextLegacyTimelineProjection,
- .suppressNextTerminalFailureLogTimelineProjection,
- .started,
- .message,
- .messageDelta,
- .log,
- .logEntry:
- false
- }
- }
-
- var triggersSyntheticStartedTurn: Bool {
- return switch self {
- case .message, .messageDelta, .log, .logEntry:
- true
- case .domainEvents,
- .suppressNextLegacyTimelineProjection,
- .suppressNextTerminalFailureLogTimelineProjection,
- .started,
- .completed,
- .failed,
- .cancelled:
- false
- }
- }
-
- func shouldDeferCompletion(awaitingReviewExit: Bool) -> Bool {
- guard case .completed(_, let result) = self else {
- return false
- }
- return awaitingReviewExit && result?.nilIfEmpty == nil
- }
-
- var activeCommandTerminalStatus: String? {
- switch self {
- case .completed:
- return "completed"
- case .failed:
- return "failed"
- case .cancelled:
- return "canceled"
- case .domainEvents,
- .suppressNextLegacyTimelineProjection,
- .suppressNextTerminalFailureLogTimelineProjection,
- .started,
- .message,
- .messageDelta,
- .log,
- .logEntry:
- return nil
- }
- }
-}
-
-private extension Array where Element == CodexReviewBackendModel.Review.Event {
- var legacyTimelineProjectionCount: Int {
- reduce(0) { count, event in
- count + (event.createsImmediateLegacyTimelineProjection ? 1 : 0)
- }
- }
-
- var addingTerminalFailureLogProjectionSuppressionIfNeeded: [Element] {
- flatMap { event -> [Element] in
- if case .failed = event {
- return [.suppressNextTerminalFailureLogTimelineProjection, event]
+ await receive(AppServerTypedReviewEventAdapter.convert(event, review: review))
}
- return [event]
- }
- }
-}
-
-private extension CodexReviewBackendModel.Review.Event {
- var createsImmediateLegacyTimelineProjection: Bool {
- guard PendingStreamedLogEntry(self) == nil else {
- return false
- }
- return switch self {
- case .message, .messageDelta, .log, .logEntry:
- true
- case .domainEvents,
- .suppressNextLegacyTimelineProjection,
- .suppressNextTerminalFailureLogTimelineProjection,
- .started,
- .completed,
- .failed,
- .cancelled:
- false
- }
- }
-}
-
-private extension ReviewDomainEvent {
- var isDirectTimelineEvent: Bool {
- switch self {
- case .itemStarted(let seed),
- .itemUpdated(let seed),
- .itemCompleted(let seed):
- seed.family.hasEquivalentDirectTimelineProjection
- case .textDelta(_, _, let family, _, _):
- family.hasEquivalentDirectTimelineProjection
- case .runStarted, .reviewCompleted, .reviewFailed, .reviewCancelled:
- false
- }
- }
-
- var isCommandProgressBoundary: Bool {
- switch self {
- case .itemStarted(let seed),
- .itemUpdated(let seed),
- .itemCompleted(let seed):
- return seed.family.isCommandProgressBoundary
- case .textDelta(_, _, let family, _, _):
- return family.isCommandProgressBoundary
- case .runStarted, .reviewCompleted, .reviewFailed, .reviewCancelled:
- return false
+ await finish(throwing: nil)
+ } catch is CancellationError {
+ await finish(throwing: CancellationError())
+ } catch {
+ await finish(throwing: error)
}
}
-}
-private extension ReviewItemFamily {
- var hasEquivalentDirectTimelineProjection: Bool {
- switch self {
- case .approval,
- .command,
- .diagnostic,
- .fileChange,
- .message,
- .plan,
- .reasoning,
- .search,
- .tool:
- true
- case .contextCompaction,
- .lifecycle,
- .unknown:
- false
- }
+ private func receive(_ converted: AppServerTypedReviewEvent) async {
+ await pipeline.receive(converted.events, controlThreadID: converted.controlThreadID)
}
-}
-private extension ReviewItemFamily {
- var isCommandProgressBoundary: Bool {
- switch self {
- case .approval,
- .contextCompaction,
- .fileChange,
- .message,
- .plan,
- .reasoning,
- .search,
- .tool,
- .unknown:
- true
- case .command,
- .diagnostic,
- .lifecycle:
- false
- }
+ private func cancelTypedReviewStream() {
+ typedReviewStreamTask?.cancel()
+ typedReviewStreamTask = nil
+ reviewSession = nil
}
}
-private final class ReviewCompletionCoordinator {
- private var pendingCompletion: CodexReviewBackendModel.Review.Event?
- private var finished = false
-
- func emit(_ event: CodexReviewBackendModel.Review.Event) -> Bool {
- guard finished == false else {
- return true
- }
- guard event.isTerminal else {
- return false
- }
- finished = true
- pendingCompletion = nil
- return true
- }
-
- func deferCompletion(_ event: CodexReviewBackendModel.Review.Event) {
- guard finished == false else {
- return
- }
- pendingCompletion = event
- }
-
- func flushPendingCompletion() -> CodexReviewBackendModel.Review.Event? {
- guard finished == false,
- let event = pendingCompletion
- else {
+private extension CodexReviewBackendModel.Review.Run {
+ var appServerReviewIdentity: CodexReviewIdentity? {
+ guard let turnID = turnID?.nilIfEmpty else {
return nil
}
- pendingCompletion = nil
- finished = true
- return event
- }
-
- func cancelPendingCompletion() {
- pendingCompletion = nil
+ let sourceThreadID = CodexThreadID(rawValue: threadID)
+ let reviewThreadID = reviewThreadID?.nilIfEmpty.map(CodexThreadID.init(rawValue:))
+ return CodexReviewIdentity(
+ threadID: sourceThreadID,
+ turnID: .init(rawValue: turnID),
+ reviewThreadID: reviewThreadID == sourceThreadID ? nil : reviewThreadID,
+ model: model
+ )
}
- func finishIfNeeded() {
- guard finished == false else {
- return
+ var appServerAssociatedThreadIDs: [String] {
+ if let identity = appServerReviewIdentity {
+ return identity.associatedThreadIDs.map(\.rawValue)
}
- finished = true
- pendingCompletion = nil
+ return [threadID]
}
-}
-private extension AppServerCodexReviewBackend {
- static func configEdits(from change: CodexReviewBackendModel.Settings.Change) -> [AppServerAPI.Config.Edit] {
- var edits: [AppServerAPI.Config.Edit] = []
- if change.updatesModel {
- edits.append(.init(
- keyPath: "review_model",
- value: change.model.map(AppServerAPI.Config.Value.string) ?? .null
- ))
- }
- if change.updatesReasoningEffort {
- edits.append(.init(
- keyPath: "model_reasoning_effort",
- value: change.reasoningEffort.map(AppServerAPI.Config.Value.string) ?? .null
- ))
- }
- if change.updatesServiceTier {
- edits.append(.init(
- keyPath: "service_tier",
- value: change.serviceTier.map(AppServerAPI.Config.Value.string) ?? .null
- ))
+ var appServerCleanupThreadIDs: [String] {
+ if let identity = appServerReviewIdentity {
+ return identity.cleanupThreadIDs.map(\.rawValue)
}
- return edits
+ return [threadID]
}
}
-private extension AppServerAPI.Account.Snapshot {
+private extension CodexAppServerKit.CodexAccount {
var backendAccount: CodexReviewBackendModel.Account.Snapshot {
- .init(
- id: id,
- kind: kind,
+ let backendKind: CodexReviewBackendModel.Account.Kind =
+ CodexReviewBackendModel.Account.Kind(rawValue: kind.rawValue) ?? .chatGPT
+ return .init(
+ id: CodexReviewBackendModel.Account.ID(id),
+ kind: backendKind,
label: label,
isActive: true,
planType: planType,
- capabilities: capabilities
+ capabilities: backendKind.capabilities
)
}
}
-private extension AppServerAPI.Account.Login.Response {
- var backendChallenge: CodexReviewBackendModel.Login.Challenge {
- get throws {
- switch self {
- case .apiKey:
- return .init(id: "api-key")
- case .chatgpt(let loginID, let authURL, let nativeWebAuthentication):
- return .init(
- id: loginID,
- verificationURL: try Self.webAuthenticationURL(authURL, field: "authUrl"),
- nativeWebAuthenticationCallbackScheme: nativeWebAuthentication?.callbackURLScheme
- )
- case .chatgptDeviceCode(let loginID, let verificationURL, let userCode):
- return .init(
- id: loginID,
- verificationURL: try Self.webAuthenticationURL(verificationURL, field: "verificationUrl"),
- userCode: userCode
- )
- case .chatgptAuthTokens:
- return .init(id: "chatgpt-auth-tokens")
+private extension CodexModel {
+ var reviewModelCatalogItem: CodexReviewSettings.ModelCatalogItem {
+ let reasoningOptions = supportedReasoningEfforts.compactMap { option in
+ CodexReviewSettings.ReasoningEffort(rawValue: option.reasoningEffort.rawValue).map {
+ CodexReviewSettings.ReasoningOption(reasoningEffort: $0, description: option.description)
}
}
- }
-
- static func webAuthenticationURL(_ string: String, field: String) throws -> URL {
- guard let components = URLComponents(string: string),
- let scheme = components.scheme?.lowercased(),
- scheme == "http" || scheme == "https",
- components.host?.isEmpty == false,
- let url = components.url
- else {
- throw CodexReviewAPI.Error.io("Invalid ChatGPT authentication URL in \(field).")
- }
- return url
- }
-}
-
-private struct TurnNotificationPayload: Decodable, Sendable {
- var threadID: String?
- var turn: AppServerNotificationTurn?
- var turnID: String?
- var itemID: String?
- var item: AppServerThreadItem?
- var startedAtMs: Int64?
- var completedAtMs: Int64?
- var reviewThreadID: String?
- var model: String?
- var fromModel: String?
- var toModel: String?
- var reason: String?
- var message: String?
- var stdin: String?
- var processID: String?
- var processHandle: String?
- var summary: String?
- var details: String?
- var delta: String?
- var deltaBase64: String?
- var diff: String?
- var result: String?
- var error: AppServerAPI.Turn.Error?
- var willRetry: Bool?
- var status: AppServerThreadStatus?
- var summaryIndex: Int?
- var contentIndex: Int?
- var plan: [AppServerTurnPlanStep]
- var verifications: [String]
-
- enum CodingKeys: String, CodingKey {
- case threadID = "threadId"
- case turn
- case turnID = "turnId"
- case itemID = "itemId"
- case item
- case startedAtMs
- case completedAtMs
- case reviewThreadID = "reviewThreadId"
- case model
- case fromModel
- case toModel
- case reason
- case message
- case stdin
- case processID = "processId"
- case processHandle
- case summary
- case details
- case delta
- case deltaBase64
- case diff
- case result
- case error
- case willRetry
- case status
- case summaryIndex
- case contentIndex
- case plan
- case verifications
- }
-
- init(from decoder: Decoder) throws {
- let container = try decoder.container(keyedBy: CodingKeys.self)
- self.threadID = try container.decodeStringIfPresent(forKey: .threadID)
- self.turn = try? container.decodeIfPresent(AppServerNotificationTurn.self, forKey: .turn)
- self.turnID = try container.decodeStringIfPresent(forKey: .turnID)
- self.itemID = try container.decodeStringIfPresent(forKey: .itemID)
- self.item = try? container.decodeIfPresent(AppServerThreadItem.self, forKey: .item)
- self.startedAtMs = try? container.decodeIfPresent(Int64.self, forKey: .startedAtMs)
- self.completedAtMs = try? container.decodeIfPresent(Int64.self, forKey: .completedAtMs)
- self.reviewThreadID = try container.decodeStringIfPresent(forKey: .reviewThreadID)
- self.model = try container.decodeStringIfPresent(forKey: .model)
- self.fromModel = try container.decodeStringIfPresent(forKey: .fromModel)
- self.toModel = try container.decodeStringIfPresent(forKey: .toModel)
- self.reason = try container.decodeStringIfPresent(forKey: .reason)
- self.message = try container.decodeStringIfPresent(forKey: .message)
- self.stdin = try container.decodeStringIfPresent(forKey: .stdin)
- self.processID = try container.decodeStringIfPresent(forKey: .processID)
- self.processHandle = try container.decodeStringIfPresent(forKey: .processHandle)
- self.summary = try container.decodeStringIfPresent(forKey: .summary)
- self.details = try container.decodeStringIfPresent(forKey: .details)
- self.delta = try container.decodeStringIfPresent(forKey: .delta)
- self.deltaBase64 = try container.decodeStringIfPresent(forKey: .deltaBase64)
- self.diff = try container.decodeStringIfPresent(forKey: .diff)
- self.result = try container.decodeStringIfPresent(forKey: .result)
- self.error = try? container.decodeIfPresent(AppServerAPI.Turn.Error.self, forKey: .error)
- self.willRetry = try? container.decodeIfPresent(Bool.self, forKey: .willRetry)
- self.status = try? container.decodeIfPresent(AppServerThreadStatus.self, forKey: .status)
- self.summaryIndex = try? container.decodeIfPresent(Int.self, forKey: .summaryIndex)
- self.contentIndex = try? container.decodeIfPresent(Int.self, forKey: .contentIndex)
- self.plan = (try? container.decodeIfPresent([AppServerTurnPlanStep].self, forKey: .plan)) ?? []
- self.verifications = (try? container.decodeIfPresent([String].self, forKey: .verifications)) ?? []
- }
-
- var resolvedTurnID: String? {
- turn?.id ?? turnID
- }
-
- var startedAt: Date? {
- startedAtMs.map(Self.date(millisecondsSince1970:))
- }
-
- var completedAt: Date? {
- completedAtMs.map(Self.date(millisecondsSince1970:))
- }
-
- private static func date(millisecondsSince1970 milliseconds: Int64) -> Date {
- Date(timeIntervalSince1970: TimeInterval(milliseconds) / 1000)
- }
-}
-
-private struct AppServerNotificationTurn: Decodable, Sendable {
- var id: String
- var status: String?
- var error: AppServerNotificationTurnError?
-
- enum CodingKeys: String, CodingKey {
- case id
- case status
- case error
- }
-
- init(from decoder: Decoder) throws {
- let container = try decoder.container(keyedBy: CodingKeys.self)
- self.id = try container.decodeStringIfPresent(forKey: .id) ?? ""
- self.status = try container.decodeStringIfPresent(forKey: .status)
- self.error = try? container.decodeIfPresent(AppServerNotificationTurnError.self, forKey: .error)
- }
-}
-
-private struct AppServerNotificationTurnError: Decodable, Sendable {
- var message: String?
-
- enum CodingKeys: String, CodingKey {
- case message
- }
-
- init(from decoder: Decoder) throws {
- let container = try decoder.container(keyedBy: CodingKeys.self)
- self.message = try container.decodeStringIfPresent(forKey: .message)
+ let defaultReasoningEffort =
+ defaultReasoningEffort
+ .flatMap { CodexReviewSettings.ReasoningEffort(rawValue: $0.rawValue) }
+ ?? reasoningOptions.first?.reasoningEffort
+ ?? .medium
+ let serviceTiers = supportedServiceTiers.compactMap(CodexReviewSettings.ServiceTier.init(rawValue:))
+ return .init(
+ id: id,
+ model: model,
+ displayName: displayName,
+ hidden: hidden,
+ supportedReasoningEfforts: reasoningOptions,
+ defaultReasoningEffort: defaultReasoningEffort,
+ supportedServiceTiers: serviceTiers,
+ isDefault: isDefault
+ )
}
}
-private struct AppServerThreadStatus: Decodable, Sendable {
- var type: String
-}
-
-private let appServerContextCompactionStartedText = "Automatically compacting context"
-private let appServerContextCompactionCompletedText = "Context automatically compacted"
-private let appServerContextCompactionFailedText = "Context compaction failed"
-private let appServerContextCompactionCancelledText = "Context compaction cancelled"
-
-private func decodeReviewNotification(
- _ notification: AppServerRoutedReviewNotification,
- fallbackReviewThreadID: String,
- commandLifecycleByItemID: inout [String: AppServerCommandLifecycle]
-) throws -> DecodedReviewNotification? {
- let payload = notification.payload
- let events: [CodexReviewBackendModel.Review.Event]
- switch notification.method {
- case "turn/started":
- events = [.started(
- turnID: payload.resolvedTurnID ?? "",
- reviewThreadID: payload.reviewThreadID ?? fallbackReviewThreadID,
- model: payload.model
- )]
- case "item/started":
- if let item = payload.item,
- item.type == "commandExecution" {
- let lifecycle = AppServerCommandLifecycle(
- item: item,
- startedAt: payload.startedAt,
- completedAt: nil
- )
- commandLifecycleByItemID[item.id] = lifecycle
- events = item.startedEvents(startedAt: payload.startedAt, lifecycle: lifecycle)
- } else {
- events = payload.item?.startedEvents(startedAt: payload.startedAt, lifecycle: nil) ?? []
- }
- case "item/updated":
- events = payload.item?.updatedEvents() ?? []
- case "item/completed":
- if let item = payload.item,
- item.type == "commandExecution" {
- let previous = commandLifecycleByItemID[item.id]
- let lifecycle = AppServerCommandLifecycle(
- item: item,
- startedAt: previous?.startedAt,
- completedAt: payload.completedAt,
- fallback: previous
- )
- events = item.completedEvents(completedAt: payload.completedAt, lifecycle: lifecycle)
- commandLifecycleByItemID.removeValue(forKey: item.id)
- } else {
- events = payload.item?.completedEvents(completedAt: payload.completedAt, lifecycle: nil) ?? []
- }
- case "item/agentMessage/delta":
- guard let delta = payload.delta,
- delta.isEmpty == false
- else {
- return nil
- }
- events = [.messageDelta(delta, itemID: payload.itemID ?? "agent-message")]
- case "item/plan/delta":
- events = payload.deltaLog(kind: .plan).map { [$0] } ?? []
- case "item/reasoning/summaryTextDelta":
- events = payload.deltaLog(
- kind: .reasoningSummary,
- groupID: payload.reasoningSummaryGroupKey
- ).map { [$0] } ?? []
- case "item/reasoning/summaryPartAdded":
- events = []
- case "item/reasoning/textDelta":
- events = payload.deltaLog(
- kind: .rawReasoning,
- groupID: payload.rawReasoningGroupKey
- ).map { [$0] } ?? []
- case "item/commandExecution/outputDelta":
- let commandOutputItemID = commandLifecycleByItemID.appendCommandOutput(
- payload.commandOutputText,
- outputID: payload.commandOutputID
- )
- events = payload.commandOutputLog(
- kind: .commandOutput,
- groupID: commandOutputItemID,
- metadata: .init(
- sourceType: "commandExecution",
- title: "Command output",
- itemID: commandOutputItemID ?? payload.commandOutputID
+private extension CodexLoginHandle {
+ func backendChallenge(
+ nativeWebAuthenticationCallbackScheme: String?
+ ) throws -> CodexReviewBackendModel.Login.Challenge {
+ switch self {
+ case .apiKey:
+ return .init(id: "api-key")
+ case .chatGPT(let loginID, let authenticationURL):
+ return .init(
+ id: loginID.rawValue,
+ verificationURL: authenticationURL,
+ nativeWebAuthenticationCallbackScheme: nativeWebAuthenticationCallbackScheme
)
- ).map { [$0] } ?? []
- case "item/fileChange/outputDelta":
- events = payload.deltaLog(
- kind: .commandOutput,
- metadata: .init(sourceType: "fileChange", title: "File change output")
- ).map { [$0] } ?? []
- case "command/exec/outputDelta",
- "process/outputDelta":
- let commandOutputItemID = commandLifecycleByItemID.appendCommandOutput(
- payload.commandOutputText,
- outputID: payload.commandOutputID
- )
- events = payload.commandOutputLog(
- kind: .commandOutput,
- groupID: commandOutputItemID,
- metadata: .init(
- sourceType: "commandExecution",
- title: "Command output",
- itemID: commandOutputItemID ?? payload.commandOutputID
+ case .chatGPTDeviceCode(let loginID, let verificationURL, let userCode):
+ return .init(
+ id: loginID.rawValue,
+ verificationURL: verificationURL,
+ userCode: userCode
)
- ).map { [$0] } ?? []
- case "item/mcpToolCall/progress":
- events = payload.messageLog(
- kind: .toolCall,
- metadata: .init(sourceType: "mcpToolCall", title: "Tool progress")
- ).map { [$0] } ?? []
- case "item/fileChange/patchUpdated":
- events = payload.itemID.map {
- [.logEntry(
- kind: .toolCall,
- text: "File changes updated.",
- groupID: $0,
- replacesGroup: false,
- metadata: .init(sourceType: "fileChange", title: "File changes", status: "updated")
- )]
- } ?? []
- case "item/commandExecution/terminalInteraction":
- events = payload.stdin?.nilIfEmpty.flatMap { stdin in
- payload.itemID.map {
- .logEntry(
- kind: .commandOutput,
- text: stdin,
- groupID: $0,
- replacesGroup: false,
- metadata: .init(sourceType: "commandExecution", title: "Terminal input")
- )
- }
- }.map { [$0] } ?? []
- case "item/autoApprovalReview/started":
- events = payload.itemID.map {
- [.logEntry(kind: .diagnostic, text: "Approval review started.", groupID: $0, replacesGroup: false)]
- } ?? [.logEntry(kind: .diagnostic, text: "Approval review started.", groupID: nil, replacesGroup: false)]
- case "item/autoApprovalReview/completed":
- events = payload.itemID.map {
- [.logEntry(kind: .diagnostic, text: "Approval review completed.", groupID: $0, replacesGroup: false)]
- } ?? [.logEntry(kind: .diagnostic, text: "Approval review completed.", groupID: nil, replacesGroup: false)]
- case "agent/message":
- events = [.message(payload.message ?? "")]
- case "log":
- events = [.log(payload.message ?? "")]
- case "turn/diff/updated":
- guard let diff = payload.diff?.nilIfEmpty else {
- return nil
- }
- events = [.logEntry(kind: .event, text: diff, groupID: payload.turnID, replacesGroup: true)]
- case "turn/plan/updated":
- guard let planText = payload.renderedPlan?.nilIfEmpty else {
- return nil
- }
- events = [.logEntry(kind: .todoList, text: planText, groupID: payload.turnID, replacesGroup: true)]
- case "turn/completed":
- switch payload.turn?.status {
- case "failed":
- events = [.failed(payload.turn?.error?.message ?? "Failed.")]
- case "interrupted":
- events = [.cancelled(payload.turn?.error?.message ?? "Cancellation requested.")]
- default:
- events = [.completed(
- summary: payload.message ?? "Succeeded.",
- result: payload.result
- )]
- }
- case "error":
- let message = payload.error?.message ?? payload.message ?? "Failed."
- events = [
- payload.willRetry == true
- ? .logEntry(kind: .progress, text: message, groupID: payload.turnID, replacesGroup: false)
- : .failed(message)
- ]
- case "turn/failed":
- events = [.failed(payload.message ?? "Failed.")]
- case "turn/cancelled":
- events = [.cancelled(payload.message ?? "Cancellation requested.")]
- case "turn/aborted":
- events = [.cancelled(payload.message ?? "Cancellation requested.")]
- case "thread/closed":
- events = [.failed("Review thread closed.")]
- case "thread/status/changed":
- switch payload.status?.type {
- case "notLoaded":
- events = [.failed("Review thread is no longer loaded.")]
- case "systemError":
- events = [.logEntry(
- kind: .diagnostic,
- text: "Review thread entered a system error state.",
- groupID: payload.turnID,
- replacesGroup: false
- )]
- default:
- return nil
- }
- case "model/rerouted":
- events = [.logEntry(kind: .event, text: payload.modelReroutedText, groupID: payload.turnID, replacesGroup: false)]
- case "model/verification":
- events = [.logEntry(kind: .diagnostic, text: payload.modelVerificationText, groupID: payload.turnID, replacesGroup: false)]
- case "thread/compacted":
- events = [.logEntry(
- kind: .contextCompaction,
- text: appServerContextCompactionCompletedText,
- groupID: payload.turnID.map { "contextCompaction:\($0)" },
- replacesGroup: true,
- metadata: .init(
- sourceType: "contextCompaction",
- status: "completed"
- )
- )]
- case "warning", "guardianWarning", "deprecationNotice", "configWarning", "diagnostic":
- guard let message = payload.diagnosticText?.nilIfEmpty else {
- return nil
- }
- events = [.logEntry(kind: .diagnostic, text: message, groupID: payload.turnID, replacesGroup: false)]
- default:
- return nil
- }
- let directEvents = directTimelineDomainEvents(
- for: notification,
- fallbackReviewThreadID: fallbackReviewThreadID
- )
- let orderedEvents: [CodexReviewBackendModel.Review.Event] = if directEvents.isEmpty {
- events
- } else {
- [.domainEvents(
- directEvents,
- legacyProjectionSuppressionCount: events.legacyTimelineProjectionCount
- )] + events.addingTerminalFailureLogProjectionSuppressionIfNeeded
- }
- return .init(
- events: orderedEvents,
- turnID: payload.resolvedTurnID,
- startsReviewMode: notification.method == "item/started" && payload.item?.type == "enteredReviewMode",
- finishesReviewMode: notification.method == "item/completed" && payload.item?.type == "exitedReviewMode",
- hasDirectTimelineEvents: directEvents.isEmpty == false
- )
-}
-
-private func directTimelineDomainEvents(
- for notification: AppServerRoutedReviewNotification,
- fallbackReviewThreadID: String
-) -> [ReviewDomainEvent] {
- guard let wireNotification = try? wireReviewNotification(from: notification) else {
- return []
- }
- return wireNotification
- .domainEvents(fallbackReviewThreadID: .init(rawValue: fallbackReviewThreadID))
- .filter { wireNotification.allowsDirectTimelineEvent($0) }
-}
-
-private extension AppServerWireReviewNotification {
- func allowsDirectTimelineEvent(_ event: ReviewDomainEvent) -> Bool {
- guard event.isDirectTimelineEvent else {
- return false
- }
- switch event {
- case .itemStarted(let seed),
- .itemUpdated(let seed),
- .itemCompleted(let seed):
- return allowsDirectTimelineSeed(seed)
- case .textDelta(let itemID, _, let family, _, _):
- if isStandaloneProcessOutputDelta {
- return false
- }
- return family.hasEquivalentDirectTimelineProjection
- && allowsDirectTimelineTextDelta(itemID: itemID, family: family)
- case .runStarted,
- .reviewCompleted,
- .reviewFailed,
- .reviewCancelled:
- return false
- }
- }
-
- private func allowsDirectTimelineSeed(_ seed: ReviewTimelineItemSeed) -> Bool {
- if payload.item?.type.rawValue == "userMessage" || seed.kind.rawValue == "userMessage" {
- return false
- }
- if seed.family == .message,
- method.rawValue == "agent/message",
- payload.itemID?.nilIfEmpty == nil {
- return false
- }
- if seed.family == .message,
- isItemLifecycleMethod,
- payload.item?.type == .agentMessage,
- payload.item?.id.nilIfEmpty == nil,
- payload.itemID?.nilIfEmpty == nil {
- return false
- }
- if seed.family == .diagnostic,
- shouldKeepSyntheticDiagnosticOnLegacyPath {
- return false
- }
- if seed.family == .search,
- seed.hasSearchResultText,
- payload.item?.result == nil {
- return false
- }
- return seed.family.hasEquivalentDirectTimelineProjection
- }
-
- private func allowsDirectTimelineTextDelta(
- itemID: ReviewTimelineItem.ID,
- family: ReviewItemFamily
- ) -> Bool {
- if family == .message,
- method.rawValue == "agent/message",
- payload.itemID?.nilIfEmpty == nil {
- return false
- }
- return itemID.rawValue.isEmpty == false
- }
-
- private var isItemLifecycleMethod: Bool {
- switch method.rawValue {
- case "item/started", "item/updated", "item/completed":
- true
- default:
- false
- }
- }
-
- private var isStandaloneProcessOutputDelta: Bool {
- switch method.rawValue {
- case "command/exec/outputDelta", "process/outputDelta":
- true
- default:
- false
- }
- }
-
- private var shouldKeepSyntheticDiagnosticOnLegacyPath: Bool {
- guard payload.itemID?.nilIfEmpty == nil else {
- return false
- }
- switch method.rawValue {
- case "log",
- "warning",
- "guardianWarning",
- "deprecationNotice",
- "configWarning",
- "diagnostic",
- "model/rerouted",
- "model/verification",
- "thread/status/changed":
- return true
- default:
- return false
- }
- }
-}
-
-private extension ReviewTimelineItemSeed {
- var hasSearchResultText: Bool {
- guard case .search(let search) = content else {
- return false
- }
- return search.result?.nilIfEmpty != nil
- }
-}
-
-private func wireReviewNotification(
- from notification: AppServerRoutedReviewNotification
-) throws -> AppServerWireReviewNotification {
- let paramsObject = try JSONSerialization.jsonObject(
- with: notification.params,
- options: [.fragmentsAllowed]
- )
- let envelope: [String: Any] = [
- "method": notification.method,
- "params": paramsObject,
- ]
- let data = try JSONSerialization.data(withJSONObject: envelope)
- return try JSONDecoder().decode(AppServerWireReviewNotification.self, from: data)
-}
-
-private func isReviewNotificationMethod(_ method: String) -> Bool {
- switch method {
- case "thread/closed",
- "thread/status/changed",
- "turn/started",
- "turn/completed",
- "turn/failed",
- "turn/cancelled",
- "turn/aborted",
- "turn/diff/updated",
- "turn/plan/updated",
- "item/started",
- "item/updated",
- "item/completed",
- "item/autoApprovalReview/started",
- "item/autoApprovalReview/completed",
- "item/agentMessage/delta",
- "item/plan/delta",
- "item/reasoning/summaryTextDelta",
- "item/reasoning/summaryPartAdded",
- "item/reasoning/textDelta",
- "item/commandExecution/outputDelta",
- "item/commandExecution/terminalInteraction",
- "command/exec/outputDelta",
- "process/outputDelta",
- "item/fileChange/outputDelta",
- "item/fileChange/patchUpdated",
- "item/mcpToolCall/progress",
- "agent/message",
- "log",
- "error",
- "model/rerouted",
- "model/verification",
- "thread/compacted",
- "warning",
- "guardianWarning",
- "deprecationNotice",
- "configWarning",
- "diagnostic":
- true
- default:
- false
- }
-}
-
-private func isThreadlessBroadcastMethod(_ method: String) -> Bool {
- switch method {
- case "warning", "deprecationNotice", "configWarning", "error":
- true
- default:
- false
- }
-}
-
-private func reasoningSummaryGroupID(itemID: String, summaryIndex: Int) -> String {
- "\(itemID):summary:\(summaryIndex)"
-}
-
-private func rawReasoningGroupID(itemID: String, contentIndex: Int) -> String {
- "\(itemID):\(contentIndex)"
-}
-
-private extension TurnNotificationPayload {
- var commandOutputID: String? {
- itemID?.nilIfEmpty ?? processID?.nilIfEmpty ?? processHandle?.nilIfEmpty
- }
-
- var commandOutputText: String? {
- if let delta, delta.isEmpty == false {
- return delta
- }
- if let decodedBase64Output, decodedBase64Output.isEmpty == false {
- return decodedBase64Output
- }
- return nil
- }
-
- func deltaLog(
- kind: ReviewLogEntry.Kind,
- groupID explicitGroupID: String? = nil,
- metadata: ReviewLogEntry.Metadata? = nil
- ) -> CodexReviewBackendModel.Review.Event? {
- guard let delta,
- delta.isEmpty == false
- else {
- return nil
- }
- return .logEntry(
- kind: kind,
- text: delta,
- groupID: explicitGroupID ?? itemID,
- replacesGroup: false,
- metadata: metadata
- )
- }
-
- func commandOutputLog(
- kind: ReviewLogEntry.Kind,
- groupID explicitGroupID: String? = nil,
- metadata: ReviewLogEntry.Metadata? = nil
- ) -> CodexReviewBackendModel.Review.Event? {
- guard let text = commandOutputText
- else {
- return nil
- }
- return .logEntry(
- kind: kind,
- text: text,
- groupID: explicitGroupID ?? commandOutputID,
- replacesGroup: false,
- metadata: metadata
- )
- }
-
- var decodedBase64Output: String? {
- guard let deltaBase64,
- let data = Data(base64Encoded: deltaBase64)
- else {
- return nil
- }
- return String(decoding: data, as: UTF8.self)
- }
-
- func messageLog(
- kind: ReviewLogEntry.Kind,
- metadata: ReviewLogEntry.Metadata? = nil
- ) -> CodexReviewBackendModel.Review.Event? {
- guard let message,
- message.isEmpty == false
- else {
- return nil
- }
- return .logEntry(
- kind: kind,
- text: message,
- groupID: itemID,
- replacesGroup: false,
- metadata: metadata
- )
- }
-
- var reasoningSummaryGroupKey: String? {
- guard let itemID else {
- return nil
- }
- return reasoningSummaryGroupID(
- itemID: itemID,
- summaryIndex: summaryIndex ?? 0
- )
- }
-
- var rawReasoningGroupKey: String? {
- guard let itemID else {
- return nil
- }
- return rawReasoningGroupID(
- itemID: itemID,
- contentIndex: contentIndex ?? 0
- )
- }
-
- var renderedPlan: String? {
- let steps = plan.map { step in
- "[\(step.status)] \(step.step)"
- }
- return steps.joined(separator: "\n").nilIfEmpty
- }
-
- var diagnosticText: String? {
- if let message = message?.nilIfEmpty {
- return message
- }
- if let summary = summary?.nilIfEmpty,
- let details = details?.nilIfEmpty
- {
- return "\(summary)\n\(details)"
- }
- return summary?.nilIfEmpty ?? details?.nilIfEmpty
- }
-
- var modelReroutedText: String {
- let route = [fromModel, toModel].compactMap { $0?.nilIfEmpty }.joined(separator: " -> ")
- let suffix = reason?.nilIfEmpty.map { " (\($0))" } ?? ""
- return route.isEmpty ? "Model rerouted\(suffix)." : "Model rerouted: \(route)\(suffix)."
- }
-
- var modelVerificationText: String {
- guard verifications.isEmpty == false else {
- return "Model verification required."
- }
- return "Model verification required: \(verifications.joined(separator: ", "))."
- }
-}
-
-private struct AppServerCommandAction: Decodable, Sendable {
- var type: String
- var command: String?
- var name: String?
- var path: String?
- var query: String?
-
- enum CodingKeys: String, CodingKey {
- case type
- case command
- case name
- case path
- case query
- }
-
- init(from decoder: Decoder) throws {
- let container = try decoder.container(keyedBy: CodingKeys.self)
- self.type = try container.decodeStringIfPresent(forKey: .type) ?? "unknown"
- self.command = try container.decodeStringIfPresent(forKey: .command)
- self.name = try container.decodeStringIfPresent(forKey: .name)
- self.path = try container.decodeStringIfPresent(forKey: .path)
- self.query = try container.decodeStringIfPresent(forKey: .query)
- }
-
- var metadataAction: ReviewLogEntry.Metadata.CommandAction {
- .init(
- kind: metadataKind,
- command: command,
- name: name,
- path: path,
- query: query
- )
- }
-
- private var metadataKind: ReviewLogEntry.Metadata.CommandAction.Kind {
- switch type {
- case "read":
- .read
- case "listFiles":
- .listFiles
- case "search":
- .search
- default:
- .unknown
- }
- }
-}
-
-private struct AppServerCommandLifecycle: Sendable {
- var itemID: String
- var command: String?
- var cwd: String?
- var processID: String?
- var startedAt: Date?
- var completedAt: Date?
- var durationMs: Int?
- var commandActions: [ReviewLogEntry.Metadata.CommandAction]?
- var commandStatus: String?
- private var streamedOutput = ""
-
- var streamedOutputIfAvailable: String? {
- streamedOutput.isEmpty ? nil : streamedOutput
- }
-
- init(
- item: AppServerThreadItem,
- startedAt: Date?,
- completedAt: Date?,
- fallback: AppServerCommandLifecycle? = nil
- ) {
- self.itemID = item.id
- self.command = item.command ?? fallback?.command
- self.cwd = item.cwd ?? fallback?.cwd
- self.processID = item.processID?.nilIfEmpty ?? fallback?.processID
- self.startedAt = startedAt ?? fallback?.startedAt
- self.completedAt = completedAt ?? fallback?.completedAt
- self.durationMs = item.durationMs ?? fallback?.durationMs
- let actions = item.metadataCommandActions
- self.commandActions = actions?.isEmpty == false ? actions : fallback?.commandActions
- self.commandStatus = item.status?.nilIfEmpty ?? fallback?.commandStatus
- self.streamedOutput = fallback?.streamedOutput ?? ""
- }
-
- mutating func appendOutput(_ output: String) {
- streamedOutput += output
- }
-
- func closingEvents(status: String, completedAt: Date) -> [CodexReviewBackendModel.Review.Event] {
- guard let command = command?.nilIfEmpty else {
- return []
- }
- var events: [CodexReviewBackendModel.Review.Event] = []
- let metadata = ReviewLogEntry.Metadata(
- sourceType: "commandExecution",
- status: status,
- itemID: itemID,
- command: command,
- cwd: cwd,
- startedAt: startedAt,
- completedAt: completedAt,
- durationMs: Self.durationMs(startedAt: startedAt, completedAt: completedAt),
- commandActions: commandActions,
- commandStatus: status
- )
- events.append(.logEntry(
- kind: .command,
- text: "$ \(command)",
- groupID: itemID,
- replacesGroup: true,
- metadata: metadata
- ))
- if streamedOutput.isEmpty == false {
- events.append(.logEntry(
- kind: .commandOutput,
- text: streamedOutput,
- groupID: itemID,
- replacesGroup: true,
- metadata: metadata
- ))
- }
- return events
- }
-
- private static func durationMs(startedAt: Date?, completedAt: Date) -> Int? {
- guard let startedAt else {
- return nil
- }
- let milliseconds = completedAt.timeIntervalSince(startedAt) * 1000
- guard milliseconds.isFinite else {
- return nil
- }
- return max(0, Int(milliseconds.rounded()))
- }
-}
-
-private extension Dictionary where Key == String, Value == AppServerCommandLifecycle {
- mutating func appendCommandOutput(_ output: String?, outputID: String?) -> String? {
- guard let outputID = outputID?.nilIfEmpty else {
- return nil
- }
- guard let output, output.isEmpty == false else {
- return commandLifecycleItemID(forOutputID: outputID) ?? outputID
- }
- if let lifecycleItemID = commandLifecycleItemID(forOutputID: outputID) {
- self[lifecycleItemID]?.appendOutput(output)
- return lifecycleItemID
- }
- return outputID
- }
-
- private func commandLifecycleItemID(forOutputID outputID: String) -> String? {
- if self[outputID] != nil {
- return outputID
- }
- return first { _, lifecycle in
- lifecycle.processID == outputID
- }?.key
- }
-
- func closeActiveCommands(for terminalEvent: CodexReviewBackendModel.Review.Event) -> [CodexReviewBackendModel.Review.Event] {
- guard let status = terminalEvent.activeCommandTerminalStatus else {
- return []
- }
- return closeActiveCommands(status: status)
- }
-
- func closeActiveCommands(status: String, completedAt: Date = Date()) -> [CodexReviewBackendModel.Review.Event] {
- values
- .sorted {
- switch ($0.startedAt, $1.startedAt) {
- case let (lhs?, rhs?) where lhs != rhs:
- return lhs < rhs
- default:
- return $0.itemID < $1.itemID
- }
- }
- .flatMap { $0.closingEvents(status: status, completedAt: completedAt) }
- }
-}
-
-private struct AppServerThreadItem: Decodable, Sendable {
- var type: String
- var id: String
- var text: String?
- var command: String?
- var cwd: String?
- var processID: String?
- var source: String?
- var aggregatedOutput: String?
- var exitCode: Int?
- var durationMs: Int?
- var commandActions: [AppServerCommandAction]
- var status: String?
- var server: String?
- var tool: String?
- var namespace: String?
- var query: String?
- var path: String?
- var review: String?
- var summary: [String]?
- var content: [String]?
- var result: AppServerNotificationValue?
- var error: AppServerNotificationValue?
- var success: Bool?
- var prompt: String?
-
- enum CodingKeys: String, CodingKey {
- case type
- case id
- case text
- case command
- case cwd
- case processID = "processId"
- case source
- case aggregatedOutput
- case exitCode
- case durationMs
- case commandActions
- case status
- case server
- case tool
- case namespace
- case query
- case path
- case review
- case summary
- case content
- case result
- case error
- case success
- case prompt
- }
-
- init(from decoder: Decoder) throws {
- let container = try decoder.container(keyedBy: CodingKeys.self)
- self.type = try container.decodeStringIfPresent(forKey: .type) ?? "unknown"
- self.id = try container.decodeStringIfPresent(forKey: .id) ?? UUID().uuidString
- self.text = try container.decodeStringIfPresent(forKey: .text)
- self.command = try container.decodeStringIfPresent(forKey: .command)
- self.cwd = try container.decodeStringIfPresent(forKey: .cwd)
- self.processID = try container.decodeStringIfPresent(forKey: .processID)
- self.source = try container.decodeStringIfPresent(forKey: .source)
- self.aggregatedOutput = try container.decodeStringIfPresent(forKey: .aggregatedOutput)
- self.exitCode = try? container.decodeIfPresent(Int.self, forKey: .exitCode)
- self.durationMs = try? container.decodeIfPresent(Int.self, forKey: .durationMs)
- self.commandActions = (try? container.decodeIfPresent([AppServerCommandAction].self, forKey: .commandActions)) ?? []
- self.status = try container.decodeStringIfPresent(forKey: .status)
- self.server = try container.decodeStringIfPresent(forKey: .server)
- self.tool = try container.decodeStringIfPresent(forKey: .tool)
- self.namespace = try container.decodeStringIfPresent(forKey: .namespace)
- self.query = try container.decodeStringIfPresent(forKey: .query)
- self.path = try container.decodeStringIfPresent(forKey: .path)
- self.review = try container.decodeStringIfPresent(forKey: .review)
- self.summary = (try? container.decodeIfPresent([String].self, forKey: .summary)) ?? []
- self.content = (try? container.decodeIfPresent([String].self, forKey: .content)) ?? []
- self.result = try? container.decodeIfPresent(AppServerNotificationValue.self, forKey: .result)
- self.error = try? container.decodeIfPresent(AppServerNotificationValue.self, forKey: .error)
- self.success = try? container.decodeIfPresent(Bool.self, forKey: .success)
- self.prompt = try container.decodeStringIfPresent(forKey: .prompt)
- }
-
- func startedEvents(
- startedAt: Date?,
- lifecycle: AppServerCommandLifecycle?
- ) -> [CodexReviewBackendModel.Review.Event] {
- switch type {
- case "userMessage":
- return []
- case "enteredReviewMode":
- return review.map { [.logEntry(kind: .progress, text: "Reviewing \($0)", groupID: id, replacesGroup: true)] } ?? []
- case "commandExecution":
- return (command ?? lifecycle?.command).map {
- [logEntry(
- kind: .command,
- text: "$ \($0)",
- replacesGroup: true,
- title: nil,
- status: "inProgress",
- startedAt: startedAt,
- completedAt: nil,
- lifecycle: lifecycle
- )]
- } ?? []
- case "mcpToolCall":
- return [logEntry(kind: .toolCall, text: "MCP \(toolLabel) started.", replacesGroup: true, title: toolLabel, status: "started")]
- case "dynamicToolCall":
- return [logEntry(kind: .toolCall, text: "Dynamic tool \(toolLabel) started.", replacesGroup: true, title: toolLabel, status: "started")]
- case "collabAgentToolCall":
- return [logEntry(kind: .toolCall, text: "Collab tool \(toolLabel) started.", replacesGroup: true, title: toolLabel, status: "started")]
- case "webSearch":
- return [logEntry(kind: .toolCall, text: "Web search: \(query ?? "started")", replacesGroup: true, title: "Web search", status: "started")]
- case "imageView":
- return [logEntry(kind: .toolCall, text: "View image: \(path ?? "image")", replacesGroup: true, title: "Image view", status: "started")]
- case "imageGeneration":
- return [logEntry(kind: .toolCall, text: "Image generation started.", replacesGroup: true, title: "Image generation", status: "started")]
- case "fileChange":
- return [logEntry(kind: .toolCall, text: "Applying file changes.", replacesGroup: true, title: "File changes", status: "started")]
- case "plan":
- return text.map { [.logEntry(kind: .plan, text: $0, groupID: id, replacesGroup: true)] } ?? []
- case "reasoning":
- return reasoningCompletionEvents(replacesGroup: true)
- case "contextCompaction":
- return [logEntry(
- kind: .contextCompaction,
- text: appServerContextCompactionStartedText,
- replacesGroup: true,
- title: nil,
- status: "inProgress",
- startedAt: startedAt
- )]
- case "hookPrompt":
- return [logEntry(kind: .event, text: "Hook prompt started.", replacesGroup: true, title: "Hook prompt", status: "started", detail: prompt)]
- case "agentMessage":
- return []
- default:
- return [.logEntry(kind: .event, text: "App-server item started: \(type).", groupID: id, replacesGroup: true)]
- }
- }
-
- func updatedEvents() -> [CodexReviewBackendModel.Review.Event] {
- switch type {
- case "agentMessage":
- return text.map {
- [logEntry(
- kind: .agentMessage,
- text: $0,
- replacesGroup: true,
- title: nil,
- status: "inProgress"
- )]
- } ?? []
- case "commandExecution":
- guard let output = aggregatedOutput?.nilIfEmpty else {
- return []
- }
- return [logEntry(
- kind: .commandOutput,
- text: output,
- replacesGroup: true,
- title: nil,
- status: status
- )]
- case "fileChange":
- guard let output = aggregatedOutput?.nilIfEmpty ?? text?.nilIfEmpty else {
- return []
- }
- return [logEntry(
- kind: .commandOutput,
- text: output,
- replacesGroup: true,
- title: "File changes",
- status: status
- )]
- case "plan":
- return text.map { [.logEntry(kind: .plan, text: $0, groupID: id, replacesGroup: true)] } ?? []
- case "reasoning":
- return reasoningCompletionEvents(replacesGroup: true)
- case "mcpToolCall",
- "dynamicToolCall",
- "collabAgentToolCall",
- "imageGeneration",
- "imageView":
- guard let text = error?.nonNullDebugText?.nilIfEmpty ?? result?.nonNullDebugText?.nilIfEmpty else {
- return []
- }
- return [logEntry(
- kind: .toolCall,
- text: text,
- replacesGroup: true,
- title: toolLabel,
- status: status
- )]
- case "webSearch":
- guard let result = result?.nonNullDebugText?.nilIfEmpty else {
- return []
- }
- return [logEntry(
- kind: .toolCall,
- text: result,
- replacesGroup: true,
- title: "Web search",
- status: status
- )]
- default:
- return []
- }
- }
-
- func completedEvents(
- completedAt: Date?,
- lifecycle: AppServerCommandLifecycle?
- ) -> [CodexReviewBackendModel.Review.Event] {
- switch type {
- case "userMessage":
- return []
- case "agentMessage":
- return text.map { [.logEntry(kind: .agentMessage, text: $0, groupID: id, replacesGroup: true)] } ?? []
- case "exitedReviewMode":
- return review.map { [.logEntry(kind: .agentMessage, text: $0, groupID: id, replacesGroup: true)] } ?? []
- case "commandExecution":
- if let output = aggregatedOutput?.nilIfEmpty ?? lifecycle?.streamedOutputIfAvailable {
- var events: [CodexReviewBackendModel.Review.Event] = []
- if let command = command ?? lifecycle?.command {
- events.append(logEntry(
- kind: .command,
- text: "$ \(command)",
- replacesGroup: true,
- title: nil,
- status: completedStatus,
- startedAt: lifecycle?.startedAt,
- completedAt: completedAt,
- lifecycle: lifecycle
- ))
- }
- events.append(logEntry(
- kind: .commandOutput,
- text: output,
- replacesGroup: true,
- title: nil,
- status: completedStatus,
- startedAt: lifecycle?.startedAt,
- completedAt: completedAt,
- lifecycle: lifecycle
- ))
- return events
- }
- if let command = command ?? lifecycle?.command {
- return [logEntry(
- kind: .command,
- text: "$ \(command)",
- replacesGroup: true,
- title: nil,
- status: completedStatus,
- startedAt: lifecycle?.startedAt,
- completedAt: completedAt,
- lifecycle: lifecycle
- )]
- }
- return []
- case "plan":
- return text.map { [.logEntry(kind: .plan, text: $0, groupID: id, replacesGroup: true)] } ?? []
- case "reasoning":
- return reasoningCompletionEvents(replacesGroup: true)
- case "mcpToolCall":
- if let text = toolResultOrErrorText {
- return [logEntry(kind: .toolCall, text: text, replacesGroup: true, title: toolLabel, status: completedStatus)]
- }
- return [logEntry(kind: .toolCall, text: "\(toolLabel) \(status ?? "completed").\(resultSuffix)", replacesGroup: true, title: toolLabel, status: completedStatus)]
- case "dynamicToolCall":
- if let text = toolResultOrErrorText {
- return [logEntry(kind: .toolCall, text: text, replacesGroup: true, title: toolLabel, status: completedStatus)]
- }
- return [logEntry(kind: .toolCall, text: "Dynamic tool \(toolLabel) \(status ?? "completed").\(resultSuffix)", replacesGroup: true, title: toolLabel, status: completedStatus)]
- case "collabAgentToolCall":
- if let text = toolResultOrErrorText {
- return [logEntry(kind: .toolCall, text: text, replacesGroup: true, title: toolLabel, status: completedStatus, detail: prompt)]
- }
- return [logEntry(kind: .toolCall, text: "Collab tool \(toolLabel) \(status ?? "completed").\(promptSuffix)", replacesGroup: true, title: toolLabel, status: completedStatus, detail: prompt)]
- case "webSearch":
- if let result = result?.nonNullDebugText?.nilIfEmpty {
- return [logEntry(kind: .toolCall, text: result, replacesGroup: true, title: "Web search", status: completedStatus)]
- }
- return [logEntry(kind: .toolCall, text: "Web search completed: \(query ?? "search").", replacesGroup: true, title: "Web search", status: completedStatus)]
- case "imageView":
- if let text = toolResultOrErrorText {
- return [logEntry(kind: .toolCall, text: text, replacesGroup: true, title: "Image view", status: completedStatus)]
- }
- return [logEntry(kind: .toolCall, text: "Image viewed: \(path ?? "image").", replacesGroup: true, title: "Image view", status: completedStatus)]
- case "imageGeneration":
- if let text = toolResultOrErrorText {
- return [logEntry(kind: .toolCall, text: text, replacesGroup: true, title: "Image generation", status: completedStatus)]
- }
- return [logEntry(kind: .toolCall, text: "Image generation \(status ?? "completed").\(resultSuffix)", replacesGroup: true, title: "Image generation", status: completedStatus)]
- case "fileChange":
- if let output = aggregatedOutput?.nilIfEmpty ?? text?.nilIfEmpty {
- return [logEntry(kind: .commandOutput, text: output, replacesGroup: true, title: "File changes", status: completedStatus)]
- }
- return [logEntry(kind: .toolCall, text: "File changes \(status ?? "completed").", replacesGroup: true, title: "File changes", status: completedStatus)]
- case "contextCompaction":
- let resolvedStatus = completedStatus
- return [logEntry(
- kind: .contextCompaction,
- text: Self.contextCompactionCompletionText(for: resolvedStatus),
- replacesGroup: true,
- title: nil,
- status: resolvedStatus,
- completedAt: completedAt
- )]
- case "hookPrompt":
- return [logEntry(kind: .event, text: "Hook prompt completed.", replacesGroup: true, title: "Hook prompt", status: completedStatus, detail: prompt)]
- case "enteredReviewMode":
- return []
- default:
- return [.logEntry(kind: .event, text: "App-server item completed: \(type).", groupID: id, replacesGroup: true)]
- }
- }
-
- private func logEntry(
- kind: ReviewLogEntry.Kind,
- text: String,
- replacesGroup: Bool,
- title: String?,
- status explicitStatus: String? = nil,
- detail: String? = nil,
- startedAt: Date? = nil,
- completedAt: Date? = nil,
- lifecycle: AppServerCommandLifecycle? = nil
- ) -> CodexReviewBackendModel.Review.Event {
- .logEntry(
- kind: kind,
- text: text,
- groupID: id,
- replacesGroup: replacesGroup,
- metadata: metadata(
- title: title,
- status: explicitStatus,
- detail: detail,
- startedAt: startedAt,
- completedAt: completedAt,
- lifecycle: lifecycle
- )
- )
- }
-
- private func metadata(
- title: String?,
- status explicitStatus: String?,
- detail: String?,
- startedAt explicitStartedAt: Date? = nil,
- completedAt explicitCompletedAt: Date? = nil,
- lifecycle: AppServerCommandLifecycle? = nil
- ) -> ReviewLogEntry.Metadata {
- let resolvedStartedAt = explicitStartedAt ?? lifecycle?.startedAt
- let resolvedCompletedAt = explicitCompletedAt ?? lifecycle?.completedAt
- let computedDurationMs = Self.durationMs(
- startedAt: resolvedStartedAt,
- completedAt: resolvedCompletedAt
- )
- let resolvedDurationMs = Self.resolvedDurationMs(
- reported: durationMs ?? lifecycle?.durationMs,
- computed: computedDurationMs
- )
- let actions = metadataCommandActions
- let resolvedCommandActions = actions?.isEmpty == false ? actions : lifecycle?.commandActions
- let explicitStatusValue = explicitStatus?.nilIfEmpty
- let itemStatus = self.status?.nilIfEmpty
- let resolvedStatus: String? = explicitStatusValue ?? itemStatus
- let resolvedCommandStatus: String? = itemStatus ?? explicitStatusValue ?? lifecycle?.commandStatus
- let isCommandExecution = type == "commandExecution"
- let isLifecycleItem = isCommandExecution || type == "contextCompaction"
- return .init(
- sourceType: type,
- title: title?.nilIfEmpty,
- status: resolvedStatus,
- detail: detail?.nilIfEmpty,
- itemID: isLifecycleItem ? id : nil,
- command: command ?? lifecycle?.command,
- cwd: cwd ?? lifecycle?.cwd,
- exitCode: exitCode,
- startedAt: isLifecycleItem ? resolvedStartedAt : nil,
- completedAt: isLifecycleItem ? resolvedCompletedAt : nil,
- durationMs: isCommandExecution ? resolvedDurationMs : nil,
- commandActions: isCommandExecution ? resolvedCommandActions : nil,
- commandStatus: isCommandExecution ? resolvedCommandStatus : nil,
- namespace: namespace,
- server: server,
- tool: tool,
- query: query,
- path: path,
- resultText: result?.nonNullDebugText?.nilIfEmpty,
- errorText: error?.nonNullDebugText?.nilIfEmpty
- )
- }
-
- var metadataCommandActions: [ReviewLogEntry.Metadata.CommandAction]? {
- guard commandActions.isEmpty == false else {
- return nil
- }
- return commandActions.map(\.metadataAction)
- }
-
- private var toolResultOrErrorText: String? {
- error?.nonNullDebugText?.nilIfEmpty ?? result?.nonNullDebugText?.nilIfEmpty
- }
-
- private static func durationMs(startedAt: Date?, completedAt: Date?) -> Int? {
- guard let startedAt, let completedAt else {
- return nil
- }
- let milliseconds = completedAt.timeIntervalSince(startedAt) * 1000
- guard milliseconds.isFinite else {
- return nil
- }
- return max(0, Int(milliseconds.rounded()))
- }
-
- private static func resolvedDurationMs(reported: Int?, computed: Int?) -> Int? {
- guard let reported else {
- return computed
- }
- guard reported <= 0,
- let computed,
- computed > 0
- else {
- return max(0, reported)
- }
- return computed
- }
-
- private var completedStatus: String? {
- if let status = status?.nilIfEmpty {
- return status
- }
- if let exitCode {
- return exitCode == 0 ? "succeeded" : "failed"
- }
- if error?.nonNullDebugText?.nilIfEmpty != nil {
- return "failed"
- }
- if let success {
- return success ? "succeeded" : "failed"
- }
- return "completed"
- }
-
- private static func contextCompactionCompletionText(for status: String?) -> String {
- let normalized = status?
- .lowercased()
- .replacingOccurrences(of: "_", with: "")
- .replacingOccurrences(of: "-", with: "")
- switch normalized {
- case "failed", "failure", "errored", "error":
- return appServerContextCompactionFailedText
- case "cancelled", "canceled":
- return appServerContextCompactionCancelledText
- default:
- return appServerContextCompactionCompletedText
- }
- }
-
- private var toolLabel: String {
- [namespace, server, tool]
- .compactMap { $0?.nilIfEmpty }
- .joined(separator: ".")
- .nilIfEmpty ?? type
- }
-
- private var resultSuffix: String {
- if let error = error?.nonNullDebugText?.nilIfEmpty {
- return " Error: \(error)"
- }
- if let result = result?.nonNullDebugText?.nilIfEmpty {
- return " Result: \(result)"
- }
- return ""
- }
-
- private var promptSuffix: String {
- prompt?.nilIfEmpty.map { " Prompt: \($0)" } ?? resultSuffix
- }
-
- private func reasoningCompletionEvents(replacesGroup: Bool) -> [CodexReviewBackendModel.Review.Event] {
- let summaryEvents = (summary ?? []).enumerated().compactMap { index, text -> CodexReviewBackendModel.Review.Event? in
- guard text.isEmpty == false else {
- return nil
- }
- return .logEntry(
- kind: .reasoningSummary,
- text: text,
- groupID: reasoningSummaryGroupID(itemID: id, summaryIndex: index),
- replacesGroup: replacesGroup
- )
- }
- let rawEvents = (content ?? []).enumerated().compactMap { index, text -> CodexReviewBackendModel.Review.Event? in
- guard text.isEmpty == false else {
- return nil
- }
- return .logEntry(
- kind: .rawReasoning,
- text: text,
- groupID: rawReasoningGroupID(itemID: id, contentIndex: index),
- replacesGroup: replacesGroup
- )
- }
- return summaryEvents + rawEvents
- }
-}
-
-private struct AppServerTurnPlanStep: Decodable, Sendable {
- var step: String
- var status: String
-}
-
-private enum AppServerNotificationValue: Decodable, Sendable {
- case string(String)
- case int(Int)
- case double(Double)
- case bool(Bool)
- case object([String: AppServerNotificationValue])
- case array([AppServerNotificationValue])
- case null
-
- init(from decoder: Decoder) throws {
- let container = try decoder.singleValueContainer()
- if container.decodeNil() {
- self = .null
- } else if let value = try? container.decode(String.self) {
- self = .string(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(Bool.self) {
- self = .bool(value)
- } else if let value = try? container.decode([String: AppServerNotificationValue].self) {
- self = .object(value)
- } else {
- self = .array(try container.decode([AppServerNotificationValue].self))
- }
- }
-
- var nonNullDebugText: String? {
- if case .null = self {
- return nil
- }
- return debugText
- }
-
- private var debugText: String {
- switch self {
- case .string(let value):
- value
- case .int(let value):
- String(value)
- case .double(let value):
- String(value)
- case .bool(let value):
- String(value)
- case .object(let value):
- Self.jsonText(value.mapValues(\.foundationObject), fallback: "{}")
- case .array(let value):
- Self.jsonText(value.map(\.foundationObject), fallback: "[]")
- case .null:
- "null"
- }
- }
-
- private var foundationObject: Any {
- switch self {
- case .string(let value):
- value
- case .int(let value):
- value
- case .double(let value):
- value
- case .bool(let value):
- value
- case .object(let value):
- value.mapValues(\.foundationObject)
- case .array(let value):
- value.map(\.foundationObject)
- case .null:
- NSNull()
- }
- }
-
- private static func jsonText(_ object: Any, fallback: String) -> String {
- guard let data = try? JSONSerialization.data(
- withJSONObject: object,
- options: [.sortedKeys]
- ),
- let text = String(data: data, encoding: .utf8)
- else {
- return fallback
- }
- return text
- }
-}
-
-private extension KeyedDecodingContainer {
- func decodeStringIfPresent(forKey key: Key) throws -> String? {
- if let value = try? decodeIfPresent(String.self, forKey: key) {
- return value
}
- return nil
}
}
diff --git a/Sources/CodexReviewAppServer/AppServerProcessTransport.swift b/Sources/CodexReviewAppServer/AppServerProcessTransport.swift
deleted file mode 100644
index f94f3694..00000000
--- a/Sources/CodexReviewAppServer/AppServerProcessTransport.swift
+++ /dev/null
@@ -1,1025 +0,0 @@
-import Darwin
-import Foundation
-import OSLog
-
-private let logger = Logger(subsystem: "CodexReviewKit", category: "app-server-transport")
-
-package actor AppServerProcessTransport: JSONRPC.Transport {
- package struct Configuration: Sendable {
- package var executable: String
- package var arguments: [String]
- package var environment: [String: String]
- package var codexHomeURL: URL
- package var threadStartPermissionStrategy: AppServerAPI.Thread.Start.PermissionStrategy
-
- package init(
- executable: String? = nil,
- arguments: [String]? = nil,
- environment: [String: String] = ProcessInfo.processInfo.environment,
- codexHomeURL: URL? = nil
- ) {
- let resolvedCodexHomeURL = codexHomeURL ?? AppServerCodexHome.url(environment: environment)
- let resolvedExecutable = executable ?? CodexAppServerExecutable.resolveExecutable(
- environment: environment
- )
- let supportsSessionSource: Bool
- if let arguments {
- supportsSessionSource = arguments.contains("--session-source")
- } else {
- supportsSessionSource = CodexAppServerExecutable.supportsAppServerSessionSource(
- executable: resolvedExecutable,
- environment: environment
- )
- }
- self.executable = resolvedExecutable
- self.arguments = arguments ?? CodexAppServerExecutable.appServerArguments(
- supportsSessionSource: supportsSessionSource
- )
- self.environment = AppServerCodexHome.environment(
- environment,
- codexHomeURL: resolvedCodexHomeURL
- )
- self.codexHomeURL = resolvedCodexHomeURL
- self.threadStartPermissionStrategy = supportsSessionSource
- ? .modernPermissions
- : .legacySandbox
- }
- }
-
- private struct PendingResponse {
- var continuation: CheckedContinuation
- }
-
- private let process: AppServerSpawnedProcess
- private let stdin: Pipe
- private let stdout: Pipe
- private let stderr: Pipe
- private let stdoutEvents: AppServerPipeReadEventSource
- private let stderrEvents: AppServerPipeReadEventSource
- private var framer = JSONRPC.Framer()
- private var pending: [Int: PendingResponse] = [:]
- private var notificationContinuations: [UUID: AsyncThrowingStream.Continuation] = [:]
- private var stderrLogFilter = AppServerStderrLogFilter()
- private var closed = false
-
- package init(configuration: Configuration = .init()) throws {
- guard FileManager.default.isExecutableFile(atPath: configuration.executable) else {
- throw AppServerProcessTransportError.executableNotFound(
- command: configuration.executable,
- path: configuration.environment["PATH"]
- )
- }
- try AppServerCodexHome.ensureScaffold(at: configuration.codexHomeURL)
- let launch = try AppServerSpawnedProcess.launch(
- executable: configuration.executable,
- arguments: configuration.arguments,
- environment: configuration.environment
- )
- let process = launch.process
- let stdin = launch.stdin
- let stdout = launch.stdout
- let stderr = launch.stderr
- self.process = process
- self.stdin = stdin
- self.stdout = stdout
- self.stderr = stderr
- let stdoutEvents = AppServerPipeReadEventSource(
- fileHandle: stdout.fileHandleForReading,
- label: "com.lynnpd.CodexReviewKit.app-server.stdout"
- )
- let stderrEvents = AppServerPipeReadEventSource(
- fileHandle: stderr.fileHandleForReading,
- label: "com.lynnpd.CodexReviewKit.app-server.stderr"
- )
- self.stdoutEvents = stdoutEvents
- self.stderrEvents = stderrEvents
- 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)")
- Task { [weak self, events = stdoutEvents.events] in
- for await event in events {
- await self?.receiveStdout(event)
- }
- }
- Task { [weak self, events = stderrEvents.events] in
- for await event in events {
- await self?.receiveStderr(event)
- }
- }
- stdoutEvents.start()
- stderrEvents.start()
- }
-
- package func send(_ request: JSONRPC.Request) async throws -> Data {
- try throwIfClosed()
- let payload = try makeRequestPayload(request)
- return try await withTaskCancellationHandler {
- try await withCheckedThrowingContinuation { continuation in
- pending[request.id] = .init(continuation: continuation)
- do {
- try stdin.fileHandleForWriting.write(contentsOf: payload)
- } catch {
- pending.removeValue(forKey: request.id)
- continuation.resume(throwing: error)
- }
- }
- } onCancel: {
- Task {
- await self.cancelPendingResponse(id: request.id)
- }
- }
- }
-
- package func notify(_ notification: JSONRPC.Notification) async throws {
- try throwIfClosed()
- let payload = try makeNotificationPayload(notification)
- try stdin.fileHandleForWriting.write(contentsOf: payload)
- }
-
- package func notificationStream() -> AsyncThrowingStream {
- AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in
- if closed {
- continuation.finish(throwing: JSONRPC.Error.closed)
- return
- }
- let id = UUID()
- notificationContinuations[id] = continuation
- continuation.onTermination = { _ in
- Task { await self.removeNotificationContinuation(id: id) }
- }
- }
- }
-
- package func close() async {
- await closeTransport(terminateProcess: true)
- }
-
- private func closeTransport(terminateProcess: Bool) async {
- guard closed == false else {
- return
- }
- closed = true
- stdoutEvents.cancel()
- stderrEvents.cancel()
- try? stdin.fileHandleForWriting.close()
- if terminateProcess {
- logger.info("Terminating codex app-server pid \(self.process.processIdentifier, privacy: .public)")
- await process.terminateAndWait()
- }
- finishAll(throwing: JSONRPC.Error.closed)
- }
-
- private func receiveStdout(_ event: AppServerPipeReadEvent) async {
- switch event {
- case .data(let data):
- receive(data)
- case .end:
- await finishReceiving()
- }
- }
-
- private func receive(_ data: Data) {
- let messages = framer.append(data)
- for message in messages {
- processMessage(message)
- }
- }
-
- private func receiveStderr(_ event: AppServerPipeReadEvent) {
- let events: [AppServerStderrLogFilter.Event]
- switch event {
- case .data(let data):
- events = stderrLogFilter.append(data)
- case .end:
- events = stderrLogFilter.finish()
- }
- for event in events {
- 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 func finishReceiving() async {
- guard closed == false else {
- return
- }
- logger.info("codex app-server stdout reached EOF")
- for message in framer.finish() {
- processMessage(message)
- }
- await closeTransport(terminateProcess: true)
- }
-
- private func processMessage(_ data: Data) {
- guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
- return
- }
- if let method = object["method"] as? String {
- if object.keys.contains("id") {
- processServerRequest(method: method, object: object)
- return
- }
- processNotification(method: method, object: object)
- } else if let id = object["id"] as? Int {
- processResponse(id: id, object: object)
- }
- }
-
- private func processServerRequest(method: String, object: [String: Any]) {
- do {
- let response = try Self.unsupportedServerRequestPayload(
- id: object["id"] ?? NSNull(),
- method: method
- )
- try stdin.fileHandleForWriting.write(contentsOf: response)
- } catch {
- logger.error("Failed to reject unsupported app-server request \(method, privacy: .public): \(error.localizedDescription, privacy: .public)")
- }
- }
-
- private func processResponse(id: Int, object: [String: Any]) {
- guard let pendingResponse = pending.removeValue(forKey: id) else {
- return
- }
- if let errorObject = object["error"] as? [String: Any] {
- let code = errorObject["code"] as? Int ?? -1
- let message = errorObject["message"] as? String ?? "JSON-RPC request failed."
- pendingResponse.continuation.resume(throwing: JSONRPC.Error.responseError(
- code: code,
- message: message
- ))
- return
- }
- let result = object["result"] ?? [:]
- do {
- let data = try Self.responsePayloadData(from: result)
- pendingResponse.continuation.resume(returning: data)
- } catch {
- pendingResponse.continuation.resume(throwing: error)
- }
- }
-
- package static func responsePayloadData(from result: Any) throws -> Data {
- if result is NSNull {
- return Data("{}".utf8)
- }
- return try JSONSerialization.data(withJSONObject: result, options: [.fragmentsAllowed])
- }
-
- package static func unsupportedServerRequestPayload(id: Any, method: String) throws -> Data {
- var data = try JSONSerialization.data(withJSONObject: [
- "id": id,
- "error": [
- "code": -32601,
- "message": "Unsupported app-server request: \(method)",
- ],
- ] as [String: Any])
- data.append(0x0A)
- return data
- }
-
- private func processNotification(method: String, object: [String: Any]) {
- let params = object["params"] ?? [:]
- guard let data = try? JSONSerialization.data(withJSONObject: params) else {
- return
- }
- let notification = JSONRPC.Notification(method: method, params: data)
- for continuation in notificationContinuations.values {
- continuation.yield(notification)
- }
- }
-
- private func cancelPendingResponse(id: Int) {
- pending.removeValue(forKey: id)?.continuation.resume(throwing: CancellationError())
- }
-
- private func removeNotificationContinuation(id: UUID) {
- notificationContinuations.removeValue(forKey: id)
- }
-
- private func finishAll(throwing error: Error) {
- let responses = pending.values
- pending.removeAll()
- for response in responses {
- response.continuation.resume(throwing: error)
- }
- let continuations = notificationContinuations.values
- notificationContinuations.removeAll()
- for continuation in continuations {
- continuation.finish(throwing: error)
- }
- }
-
- private func throwIfClosed() throws {
- if closed {
- throw JSONRPC.Error.closed
- }
- }
-}
-
-private struct AppServerProcessLaunch {
- var process: AppServerSpawnedProcess
- var stdin: Pipe
- var stdout: Pipe
- var stderr: Pipe
-}
-
-private enum AppServerPipeReadEvent: Sendable {
- case data(Data)
- case end
-}
-
-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: ")
- }
-}
-
-private final class AppServerPipeReadEventSource: @unchecked Sendable {
- let events: AsyncStream
-
- private let fileHandle: FileHandle
- private let queue: DispatchQueue
- private let continuationLock = NSLock()
- private var continuation: AsyncStream.Continuation?
-
- init(fileHandle: FileHandle, label: String) {
- self.fileHandle = fileHandle
- self.queue = DispatchQueue(label: label)
- var continuation: AsyncStream.Continuation?
- self.events = AsyncStream(bufferingPolicy: .unbounded) { streamContinuation in
- continuation = streamContinuation
- }
- self.continuation = continuation
- }
-
- func start() {
- fileHandle.readabilityHandler = { [weak self] handle in
- self?.queue.async { [weak self] in
- guard let self else {
- return
- }
- let data = handle.availableData
- if data.isEmpty {
- finish(with: .end)
- return
- }
- yield(.data(data))
- }
- }
- }
-
- func cancel() {
- fileHandle.readabilityHandler = nil
- finish()
- }
-
- private func yield(_ event: AppServerPipeReadEvent) {
- continuationLock.lock()
- let continuation = continuation
- continuationLock.unlock()
- continuation?.yield(event)
- }
-
- private func finish(with finalEvent: AppServerPipeReadEvent? = nil) {
- continuationLock.lock()
- let continuation = continuation
- self.continuation = nil
- continuationLock.unlock()
- if let finalEvent {
- continuation?.yield(finalEvent)
- }
- continuation?.finish()
- }
-}
-
-private final class AppServerSpawnedProcess: @unchecked Sendable {
- let processIdentifier: pid_t
-
- private let processGroupID: pid_t
- private let stateLock = NSLock()
- private var didReap = false
-
- private init(processIdentifier: pid_t) {
- self.processIdentifier = processIdentifier
- self.processGroupID = 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 [
- 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 terminateAndWait(
- graceDuration: Duration = .seconds(2),
- killDuration: Duration = .seconds(1)
- ) async {
- let trackedProcessIDs = descendantProcessIDs()
- guard isFullyTerminated(trackedProcessIDs: trackedProcessIDs) == false else {
- return
- }
- signalProcessTree(SIGTERM, trackedProcessIDs: trackedProcessIDs)
- guard await waitUntilExit(timeout: graceDuration, trackedProcessIDs: trackedProcessIDs) == false else {
- return
- }
- signalProcessTree(SIGKILL, trackedProcessIDs: trackedProcessIDs)
- _ = await waitUntilExit(timeout: killDuration, trackedProcessIDs: trackedProcessIDs)
- }
-
- private func signalProcessTree(_ signal: Int32, trackedProcessIDs: Set) {
- if Darwin.kill(-processGroupID, signal) == 0 {
- for processID in trackedProcessIDs {
- _ = Darwin.kill(processID, signal)
- }
- return
- }
- for processID in trackedProcessIDs {
- _ = Darwin.kill(processID, signal)
- }
- _ = Darwin.kill(processIdentifier, signal)
- }
-
- private func waitUntilExit(timeout: Duration, trackedProcessIDs: Set) async -> Bool {
- let clock = ContinuousClock()
- let deadline = clock.now + timeout
- while isFullyTerminated(trackedProcessIDs: trackedProcessIDs) == false {
- if clock.now >= deadline {
- return false
- }
- try? await Task.sleep(for: .milliseconds(50))
- }
- return true
- }
-
- private func isFullyTerminated(trackedProcessIDs: Set) -> Bool {
- reapIfExited()
- && processGroupIsEmpty()
- && trackedProcessIDs.allSatisfy(Self.processIsGone)
- }
-
- private func processGroupIsEmpty() -> Bool {
- if Darwin.kill(-processGroupID, 0) == 0 {
- return false
- }
- return errno == ESRCH
- }
-
- private static func processIsGone(_ processID: pid_t) -> Bool {
- if Darwin.kill(processID, 0) == 0 {
- return false
- }
- return errno == ESRCH
- }
-
- private func descendantProcessIDs() -> Set {
- let parentByProcessID = Self.parentProcessMap()
- var descendants = Set()
- var stack = [processIdentifier]
- while let parent = stack.popLast() {
- for (processID, parentProcessID) in parentByProcessID where parentProcessID == parent {
- if descendants.insert(processID).inserted {
- stack.append(processID)
- }
- }
- }
- return descendants
- }
-
- private static func parentProcessMap() -> [pid_t: pid_t] {
- let bytesNeeded = proc_listpids(UInt32(PROC_ALL_PIDS), 0, nil, 0)
- guard bytesNeeded > 0 else {
- return [:]
- }
- let processIDSize = MemoryLayout.stride
- var processIDs = [pid_t](repeating: 0, count: Int(bytesNeeded) / processIDSize)
- let bytesWritten = processIDs.withUnsafeMutableBufferPointer { buffer in
- proc_listpids(
- UInt32(PROC_ALL_PIDS),
- 0,
- buffer.baseAddress,
- Int32(buffer.count * processIDSize)
- )
- }
- guard bytesWritten > 0 else {
- return [:]
- }
- let count = min(Int(bytesWritten) / processIDSize, processIDs.count)
- var parentByProcessID: [pid_t: pid_t] = [:]
- for processID in processIDs.prefix(count) where processID > 0 {
- var info = proc_bsdinfo()
- let infoSize = MemoryLayout.stride
- let result = proc_pidinfo(
- processID,
- PROC_PIDTBSDINFO,
- 0,
- &info,
- Int32(infoSize)
- )
- if result == Int32(infoSize) {
- parentByProcessID[processID] = pid_t(info.pbi_ppid)
- }
- }
- return parentByProcessID
- }
-
- private func reapIfExited() -> Bool {
- stateLock.lock()
- defer { stateLock.unlock() }
- if didReap {
- return true
- }
- var status: Int32 = 0
- let result = waitpid(processIdentifier, &status, WNOHANG)
- if result == processIdentifier {
- didReap = true
- return true
- }
- if result == -1, errno == ECHILD {
- didReap = true
- return true
- }
- return false
- }
-
- 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)
- }
- }
-}
-
-private enum AppServerProcessTransportError: LocalizedError {
- case executableNotFound(command: String, path: String?)
-
- var errorDescription: String? {
- switch self {
- case .executableNotFound(let command, let path):
- let resolvedPath = path?.trimmingCharacters(in: .whitespacesAndNewlines)
- if let resolvedPath, resolvedPath.isEmpty == false {
- return "Unable to locate \(command) executable in PATH: \(resolvedPath)"
- }
- return "Unable to locate \(command) executable. Set PATH so codex can be found."
- }
- }
-}
-
-package enum AppServerCodexHome {
- package static func url(
- environment: [String: String] = ProcessInfo.processInfo.environment,
- homeDirectoryForCurrentUser: URL = FileManager.default.homeDirectoryForCurrentUser
- ) -> URL {
- if let codexHome = environment["CODEX_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines),
- codexHome.isEmpty == false
- {
- return URL(fileURLWithPath: codexHome, isDirectory: true)
- }
- if let home = environment["HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines),
- home.isEmpty == false
- {
- return URL(fileURLWithPath: home, isDirectory: true)
- .appendingPathComponent(".codex_review", isDirectory: true)
- }
- return homeDirectoryForCurrentUser
- .appendingPathComponent(".codex_review", isDirectory: true)
- }
-
- 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(for: executable, environment: environment)
- )
- }
-
- package static func resolveExecutable(
- environment: [String: String] = ProcessInfo.processInfo.environment
- ) -> String {
- let requestedCommand = [
- environment["CODEX_REVIEW_CODEX_EXECUTABLE"],
- environment["CODEX_EXECUTABLE"],
- ].compactMap(\.self).first ?? "codex"
-
- if let candidate = findExecutable(
- requestedCommand,
- environment: environment
- ) {
- return candidate
- }
-
- return requestedCommand
- }
-
- package static func appServerArguments(
- for executable: String,
- environment: [String: String] = ProcessInfo.processInfo.environment
- ) -> [String] {
- appServerArguments(
- supportsSessionSource: supportsAppServerSessionSource(
- executable: executable,
- environment: environment
- )
- )
- }
-
- package static func appServerArguments(supportsSessionSource: Bool = false) -> [String] {
- var arguments = [
- "-c", fileBackedAuthConfiguration,
- "app-server",
- "--listen", "stdio://",
- ]
- if supportsSessionSource {
- arguments.append(contentsOf: ["--session-source", "app-server"])
- }
- return arguments
- }
-
- 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 supportsAppServerSessionSource(
- executable: String,
- environment: [String: String]
- ) -> Bool {
- guard FileManager.default.isExecutableFile(atPath: executable) else {
- return false
- }
-
- let process = Process()
- process.executableURL = URL(fileURLWithPath: executable)
- process.arguments = ["app-server", "--help"]
- process.environment = environment
- let pipe = Pipe()
- process.standardOutput = pipe
- process.standardError = pipe
-
- do {
- try process.run()
- } catch {
- return false
- }
-
- let deadline = Date().addingTimeInterval(2)
- while process.isRunning && Date() < deadline {
- Thread.sleep(forTimeInterval: 0.01)
- }
- guard process.isRunning == false else {
- process.terminate()
- return false
- }
-
- let data = pipe.fileHandleForReading.readDataToEndOfFile()
- let help = String(decoding: data, as: UTF8.self)
- // Deprecated compatibility: installed Codex builds can reject this newer app-server flag.
- // Remove the probe once the packaged Codex app-server consistently accepts --session-source.
- return help.contains("--session-source")
- }
-
- package static func pathSearchDirectories(environment: [String: String]) -> [String] {
- let environmentDirectories = (environment["PATH"] ?? "")
- .split(separator: ":", omittingEmptySubsequences: true)
- .map(String.init)
- var directories: [String] = []
- for directory in environmentDirectories + [
- "/Applications/Codex.app/Contents/Resources",
- "/opt/homebrew/bin",
- "/usr/local/bin",
- "/usr/bin",
- "/bin",
- "/usr/sbin",
- "/sbin",
- ] 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/CodexReviewAppServer/AppServerRequests.swift b/Sources/CodexReviewAppServer/AppServerRequests.swift
deleted file mode 100644
index 281e3550..00000000
--- a/Sources/CodexReviewAppServer/AppServerRequests.swift
+++ /dev/null
@@ -1,1245 +0,0 @@
-import Foundation
-import CodexReview
-
-package enum AppServerAPI {
- package enum Initialize {}
- package enum Thread {
- 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 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 RateLimits {
- package enum Read {}
- }
- package enum Login {
- package enum Complete {}
- package enum Cancel {}
- }
- }
-}
-
-package extension AppServerAPI {
-enum RequestScope: Hashable, Sendable {
- case thread(String)
-}
-}
-
-
-package extension AppServerAPI.Thread.Start {
-enum PermissionStrategy: Equatable, Sendable {
- case modernPermissions
- case legacySandbox
-}
-}
-
-
-package extension AppServerAPI {
-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 extension AppServerAPI.Initialize {
-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
- }
-}
-}
-
-
-package extension AppServerAPI.Initialize {
-struct Capabilities: Codable, Equatable, Sendable {
- package var experimentalAPI: Bool
-
- enum CodingKeys: String, CodingKey {
- case experimentalAPI = "experimentalApi"
- }
-
- package init(experimentalAPI: Bool = true) {
- self.experimentalAPI = experimentalAPI
- }
-}
-}
-
-
-package extension AppServerAPI.Initialize {
-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()
- }
-}
-}
-
-
-package extension AppServerAPI.Initialize {
-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
- }
-}
-}
-
-
-package extension AppServerAPI.Initialize {
-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
- }
-}
-}
-
-
-package extension AppServerAPI.Thread.Start {
-struct Params: Codable, Equatable, Sendable {
- package var cwd: String
- package var model: String?
- package var ephemeral: Bool?
- package var approvalPolicy: String?
- package var sandbox: String?
- 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?
-
- package init(
- cwd: String,
- model: String? = nil,
- ephemeral: Bool? = nil,
- approvalPolicy: String? = nil,
- sandbox: String? = nil,
- permissions: AppServerAPI.Thread.Start.Permissions? = nil,
- sessionStartSource: AppServerAPI.Thread.Start.Source? = nil,
- threadSource: AppServerAPI.Thread.Source? = nil
- ) {
- self.cwd = cwd
- self.model = model
- self.ephemeral = ephemeral
- self.approvalPolicy = approvalPolicy
- self.sandbox = sandbox
- self.permissions = permissions
- self.sessionStartSource = sessionStartSource
- self.threadSource = threadSource
- }
-}
-}
-
-
-package extension AppServerAPI.Thread.Start {
-enum Source: String, Codable, Equatable, Sendable {
- case startup
- case clear
-}
-}
-
-
-package extension AppServerAPI.Thread {
-enum Source: String, Codable, Equatable, Sendable {
- case user
- case subagent
- case memoryConsolidation = "memory_consolidation"
-}
-}
-
-
-package extension AppServerAPI.Thread.Start {
-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)
- }
- }
-}
-}
-
-
-package extension AppServerAPI.Thread.Start {
-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
- }
-}
-}
-
-
-package extension AppServerAPI.Thread.Start {
-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)
- }
-}
-}
-
-
-package extension AppServerAPI.Thread.Start {
-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
- }
-}
-}
-
-
-package extension AppServerAPI.Review.Start {
-struct Params: Codable, Equatable, Sendable {
- package var threadID: String
- package var target: CodexReviewAPI.Target
- package var delivery: AppServerAPI.Review.Start.Delivery
-
- enum CodingKeys: String, CodingKey {
- case threadID = "threadId"
- case target
- case delivery
- }
-
- package init(
- threadID: String,
- target: CodexReviewAPI.Target,
- delivery: AppServerAPI.Review.Start.Delivery = .inline
- ) {
- self.threadID = threadID
- self.target = target
- self.delivery = delivery
- }
-}
-}
-
-
-package extension AppServerAPI.Review.Start {
-enum Delivery: String, Codable, Equatable, Sendable {
- case inline
- case detached
-}
-}
-
-
-package extension AppServerAPI.Turn {
-struct Payload: Codable, Equatable, Sendable {
- package var id: String
- package var status: String?
- package var error: AppServerAPI.Turn.Error?
-
- package init(id: String, status: String? = nil, error: AppServerAPI.Turn.Error? = nil) {
- self.id = id
- self.status = status
- self.error = error
- }
-}
-}
-
-
-package extension AppServerAPI.Turn {
-struct Error: Codable, Equatable, Sendable {
- package var message: String
-
- package init(message: String) {
- self.message = message
- }
-}
-}
-
-
-package extension AppServerAPI.Review.Start {
-struct Response: Codable, Equatable, Sendable {
- package var turnID: String
- package var reviewThreadID: String?
-
- enum CodingKeys: String, CodingKey {
- case turn
- case reviewThreadID = "reviewThreadId"
- }
-
- package init(turnID: String, reviewThreadID: String? = nil) {
- self.turnID = turnID
- self.reviewThreadID = reviewThreadID
- }
-
- package init(from decoder: Decoder) throws {
- let container = try decoder.container(keyedBy: CodingKeys.self)
- self.turnID = try container.decode(AppServerAPI.Turn.Payload.self, forKey: .turn).id
- self.reviewThreadID = try container.decodeIfPresent(String.self, forKey: .reviewThreadID)
- }
-
- package func encode(to encoder: Encoder) throws {
- var container = encoder.container(keyedBy: CodingKeys.self)
- try container.encode(AppServerAPI.Turn.Payload(id: turnID), forKey: .turn)
- try container.encodeIfPresent(reviewThreadID, forKey: .reviewThreadID)
- }
-}
-}
-
-
-package extension AppServerAPI.Review.Start {
-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
- }
-}
-}
-
-
-package extension AppServerAPI.Turn.Interrupt {
-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
- }
-}
-}
-
-
-package extension AppServerAPI.Turn.Interrupt {
-struct Request: AppServerAPI.Request {
- package typealias Response = EmptyResponse
-
- package static let method = "turn/interrupt"
- package var params: AppServerAPI.Turn.Interrupt.Params
-
- package init(params: AppServerAPI.Turn.Interrupt.Params) {
- self.params = params
- }
-}
-}
-
-
-package extension AppServerAPI.Thread.Rollback {
-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
- }
-}
-}
-
-
-package extension AppServerAPI.Thread.Rollback {
-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
- }
-}
-}
-
-
-package extension AppServerAPI.Thread.Delete {
-struct Params: Codable, Equatable, Sendable {
- package var threadID: String
-
- enum CodingKeys: String, CodingKey {
- case threadID = "threadId"
- }
-
- package init(threadID: String) {
- self.threadID = threadID
- }
-}
-}
-
-
-package extension AppServerAPI.Thread.Delete {
-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
- }
-}
-}
-
-
-package extension AppServerAPI.Thread.Unsubscribe {
-struct Params: Codable, Equatable, Sendable {
- package var threadID: String
-
- enum CodingKeys: String, CodingKey {
- case threadID = "threadId"
- }
-
- package init(threadID: String) {
- self.threadID = threadID
- }
-}
-}
-
-
-package extension AppServerAPI.Thread.Unsubscribe {
-enum Status: String, Codable, Equatable, Sendable {
- case notLoaded
- case notSubscribed
- case unsubscribed
-}
-}
-
-
-package extension AppServerAPI.Thread.Unsubscribe {
-struct Response: Codable, Equatable, Sendable {
- package var status: AppServerAPI.Thread.Unsubscribe.Status
-
- package init(status: AppServerAPI.Thread.Unsubscribe.Status) {
- self.status = status
- }
-}
-}
-
-
-package extension AppServerAPI.Thread.Unsubscribe {
-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
- }
-}
-}
-
-
-package extension AppServerAPI.Thread.BackgroundTerminals.Clean {
-struct Params: Codable, Equatable, Sendable {
- package var threadID: String
-
- enum CodingKeys: String, CodingKey {
- case threadID = "threadId"
- }
-
- package init(threadID: String) {
- self.threadID = threadID
- }
-}
-}
-
-
-package extension AppServerAPI.Thread.BackgroundTerminals.Clean {
-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
- }
-}
-}
-
-
-package extension AppServerAPI.Config.Read {
-struct Response: Codable, Equatable, Sendable {
- package var config: AppServerAPI.Config.Snapshot
-
- package init(config: AppServerAPI.Config.Snapshot) {
- self.config = config
- }
-}
-}
-
-
-package extension AppServerAPI.Config {
-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
- }
-}
-}
-
-
-package extension AppServerAPI.Config.Read {
-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()
- }
-}
-}
-
-
-package extension AppServerAPI.Config {
-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()
- }
- }
-}
-}
-
-
-package extension AppServerAPI.Config {
-enum MergeStrategy: String, Codable, Equatable, Sendable {
- case replace
- case upsert
-}
-}
-
-
-package extension AppServerAPI.Config {
-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
- }
-}
-}
-
-
-package extension AppServerAPI.Config.BatchWrite {
-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
- }
-}
-}
-
-
-package extension AppServerAPI.Config.BatchWrite {
-struct Response: Decodable, Equatable, Sendable {
- package var status: String
- package var version: String?
- package var filePath: String?
-}
-}
-
-
-package extension AppServerAPI.Config.BatchWrite {
-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
- }
-}
-}
-
-
-package extension AppServerAPI.Model.List {
-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
- }
-}
-}
-
-
-package extension AppServerAPI.Model.List {
-struct Response: Codable, Equatable, Sendable {
- package var data: [CodexReviewSettings.ModelCatalogItem]
- package var nextCursor: String?
-
- package init(
- data: [CodexReviewSettings.ModelCatalogItem],
- nextCursor: String? = nil
- ) {
- self.data = data
- self.nextCursor = nextCursor
- }
-}
-}
-
-
-package extension AppServerAPI.Model.List {
-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
- }
-}
-}
-
-
-package extension AppServerAPI.Auth.Read {
-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)
- }
-}
-}
-
-
-package extension AppServerAPI.Account.Read {
-struct Params: Codable, Equatable, Sendable {
- package var refreshToken: Bool
-
- package init(refreshToken: Bool) {
- self.refreshToken = refreshToken
- }
-}
-}
-
-
-package extension AppServerAPI.Account.Read {
-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
- }
-}
-}
-
-
-package extension AppServerAPI.Account.RateLimits.Read {
-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()
- }
-}
-}
-
-
-package extension AppServerAPI.Account.RateLimits {
-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
- }
-}
-}
-
-
-package extension AppServerAPI.Account.RateLimits {
-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
- }
-}
-}
-
-
-package extension AppServerAPI.Account.RateLimits {
-struct Window: Codable, Equatable, Sendable {
- package var usedPercent: Int
- package var windowDurationMins: Int?
- package var resetsAt: Int64?
-
- package init(
- usedPercent: Int,
- windowDurationMins: Int? = nil,
- resetsAt: Int64? = nil
- ) {
- self.usedPercent = usedPercent
- self.windowDurationMins = windowDurationMins
- self.resetsAt = resetsAt
- }
-}
-}
-
-
-package extension AppServerAPI.Account.RateLimits.Response {
- var codexRateLimitWindows: [(windowDurationMinutes: Int, usedPercent: Int, resetsAt: Date?)] {
- Self.rateLimitWindows(from: codexSnapshot)
- }
-
- 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)) }
- )
- }
- }
-
- static func isCodexRateLimit(_ limitID: String?) -> Bool {
- let normalizedLimitID = limitID?.nilIfEmpty ?? "codex"
- return normalizedLimitID == "codex" || normalizedLimitID.hasPrefix("codex_")
- }
-}
-
-
-package extension AppServerAPI.Account {
-struct Snapshot: Codable, Equatable, Sendable {
- package var id: CodexReviewBackendModel.Account.ID
- package var kind: CodexReviewBackendModel.Account.Kind
- package var label: String
- package var planType: String?
- package var capabilities: CodexReviewBackendModel.Account.Capabilities
-
- package init(email: String, planType: String) {
- self.init(
- kind: .chatGPT,
- id: .init(CodexAccount.normalizedEmail(email)),
- label: email,
- planType: planType,
- capabilities: .supportsCodexRateLimits
- )
- }
-
- fileprivate init(
- kind: CodexReviewBackendModel.Account.Kind,
- id: CodexReviewBackendModel.Account.ID,
- label: String,
- planType: String?,
- capabilities: CodexReviewBackendModel.Account.Capabilities
- ) {
- self.id = id
- self.kind = kind
- self.label = label
- self.planType = planType
- self.capabilities = capabilities
- }
-
- package init(from decoder: Decoder) throws {
- let container = try decoder.container(keyedBy: AppServerAccountCodingKeys.self)
- let kind = try container.decode(CodexReviewBackendModel.Account.Kind.self, forKey: .type)
- let descriptor = AppServerAccountKindDescriptor.descriptor(for: kind)
- self = try descriptor.decode(container)
- }
-
- package func encode(to encoder: Encoder) throws {
- var container = encoder.container(keyedBy: AppServerAccountCodingKeys.self)
- try container.encode(kind, forKey: .type)
- let descriptor = AppServerAccountKindDescriptor.descriptor(for: kind)
- try descriptor.encodeFields(self, &container)
- }
-}
-}
-
-
-private enum AppServerAccountCodingKeys: String, CodingKey {
- case type
- case email
- case planType
-}
-
-private struct AppServerAccountKindDescriptor {
- var decode: (KeyedDecodingContainer) throws -> AppServerAPI.Account.Snapshot
- var encodeFields: (AppServerAPI.Account.Snapshot, inout KeyedEncodingContainer) throws -> Void
-
- static func descriptor(for kind: CodexReviewBackendModel.Account.Kind) -> Self {
- switch kind {
- case .apiKey:
- fixed(
- kind: .apiKey,
- id: "api-key",
- label: "API Key",
- capabilities: .noCodexRateLimits
- )
- case .chatGPT:
- .init(
- decode: { container in
- let email = try container.decode(String.self, forKey: .email)
- let normalizedEmail = CodexAccount.normalizedEmail(email)
- guard normalizedEmail.isEmpty == false else {
- throw DecodingError.dataCorruptedError(
- forKey: .email,
- in: container,
- debugDescription: "ChatGPT account email must not be empty."
- )
- }
- return AppServerAPI.Account.Snapshot(
- kind: .chatGPT,
- id: .init(normalizedEmail),
- label: email,
- planType: try container.decode(String.self, forKey: .planType),
- capabilities: .supportsCodexRateLimits
- )
- },
- encodeFields: { account, container in
- guard let planType = account.planType else {
- throw EncodingError.invalidValue(
- account,
- .init(
- codingPath: container.codingPath + [AppServerAccountCodingKeys.planType],
- debugDescription: "ChatGPT account planType must not be nil."
- )
- )
- }
- try container.encode(account.label, forKey: .email)
- try container.encode(planType, forKey: .planType)
- }
- )
- case .amazonBedrock:
- fixed(
- kind: .amazonBedrock,
- id: "amazon-bedrock",
- label: "Amazon Bedrock",
- capabilities: .noCodexRateLimits
- )
- }
- }
-
- private static func fixed(
- kind: CodexReviewBackendModel.Account.Kind,
- id: String,
- label: String,
- capabilities: CodexReviewBackendModel.Account.Capabilities
- ) -> Self {
- .init(
- decode: { _ in
- AppServerAPI.Account.Snapshot(
- kind: kind,
- id: .init(id),
- label: label,
- planType: nil,
- capabilities: capabilities
- )
- },
- encodeFields: { _, _ in }
- )
- }
-}
-
-package extension AppServerAPI.Account.Login {
-struct Params: Codable, Equatable, Sendable {
- package var type: String
- package var codexStreamlinedLogin: Bool
- package var nativeWebAuthentication: AppServerAPI.Account.Login.NativeWebAuthentication?
-
- package init(
- type: String = "chatgpt",
- codexStreamlinedLogin: Bool = true,
- nativeWebAuthentication: AppServerAPI.Account.Login.NativeWebAuthentication? = nil
- ) {
- self.type = type
- self.codexStreamlinedLogin = codexStreamlinedLogin
- self.nativeWebAuthentication = nativeWebAuthentication
- }
-}
-}
-
-
-package extension AppServerAPI.Account.Login {
-struct NativeWebAuthentication: Codable, Equatable, Sendable {
- package var callbackURLScheme: String
-
- enum CodingKeys: String, CodingKey {
- case callbackURLScheme = "callbackUrlScheme"
- }
-
- package init(callbackURLScheme: String) {
- self.callbackURLScheme = callbackURLScheme
- }
-}
-}
-
-
-package extension AppServerAPI.Account.Login {
-enum Response: Codable, Equatable, Sendable {
- case apiKey
- case chatgpt(
- loginID: String,
- authURL: String,
- nativeWebAuthentication: AppServerAPI.Account.Login.NativeWebAuthentication?
- )
- case chatgptDeviceCode(loginID: String, verificationURL: String, userCode: String)
- case chatgptAuthTokens
-
- private enum CodingKeys: String, CodingKey {
- case type
- case loginID = "loginId"
- case authURL = "authUrl"
- case nativeWebAuthentication
- 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),
- nativeWebAuthentication: try container.decodeIfPresent(
- AppServerAPI.Account.Login.NativeWebAuthentication.self,
- forKey: .nativeWebAuthentication
- )
- )
- 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, let nativeWebAuthentication):
- try container.encode("chatgpt", forKey: .type)
- try container.encode(loginID, forKey: .loginID)
- try container.encode(authURL, forKey: .authURL)
- try container.encodeIfPresent(nativeWebAuthentication, forKey: .nativeWebAuthentication)
- 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)
- }
- }
-}
-}
-
-
-package extension AppServerAPI.Account.Login.Complete {
-struct Params: Codable, Equatable, Sendable {
- package var loginID: String
- package var callbackURL: String
-
- enum CodingKeys: String, CodingKey {
- case loginID = "loginId"
- case callbackURL = "callbackUrl"
- }
-
- package init(loginID: String, callbackURL: String) {
- self.loginID = loginID
- self.callbackURL = callbackURL
- }
-}
-}
-
-
-package extension AppServerAPI.Account.Login.Complete {
-struct Response: Codable, Equatable, Sendable {
- package init() {}
-}
-}
-
-
-package extension AppServerAPI.Account.Login.Cancel {
-struct Params: Codable, Equatable, Sendable {
- package var loginID: String
-
- enum CodingKeys: String, CodingKey {
- case loginID = "loginId"
- }
-
- package init(loginID: String) {
- self.loginID = loginID
- }
-}
-}
-
-
-package extension AppServerAPI.Account.Login.Cancel {
-struct Response: Codable, Equatable, Sendable {
- package var status: String
-
- package init(status: String = "canceled") {
- self.status = status
- }
-}
-}
diff --git a/Sources/CodexReviewAppServer/AppServerReviewControl.swift b/Sources/CodexReviewAppServer/AppServerReviewControl.swift
deleted file mode 100644
index 1e8f60a9..00000000
--- a/Sources/CodexReviewAppServer/AppServerReviewControl.swift
+++ /dev/null
@@ -1,130 +0,0 @@
-import Foundation
-import CodexReview
-
-package struct AppServerReviewInterruption: Equatable, Sendable {
- package var threadID: String
- package var turnID: String
-
- package init(threadID: String, turnID: String) {
- self.threadID = threadID
- self.turnID = turnID
- }
-}
-
-package final class AppServerReviewControl: @unchecked Sendable {
- private enum Phase: Equatable {
- case preparing
- case threadStarted(threadID: String)
- case reviewStarted(turnThreadID: String, turnID: String)
- case finished
- }
-
- private let client: AppServerClient
- private let phaseLock = NSLock()
- private var phase: Phase = .preparing
-
- package init(client: AppServerClient) {
- self.client = client
- }
-
- package func recordThreadStarted(threadID: String) {
- phaseLock.lock()
- defer { phaseLock.unlock() }
- guard phase == .preparing else {
- return
- }
- phase = .threadStarted(threadID: threadID)
- }
-
- package func recordReviewStarted(turnThreadID: String, turnID: String) {
- phaseLock.lock()
- defer { phaseLock.unlock() }
- phase = .reviewStarted(turnThreadID: turnThreadID, turnID: turnID)
- }
-
- package func recordTurnStarted(turnThreadID: String, turnID: String) {
- phaseLock.lock()
- defer { phaseLock.unlock() }
- phase = .reviewStarted(turnThreadID: turnThreadID, turnID: turnID)
- }
-
- @discardableResult
- package func interrupt(
- willInterruptActiveTurn: (@Sendable (AppServerReviewInterruption) async -> Void)? = nil
- ) async throws -> AppServerReviewInterruption? {
- let currentPhase = phaseSnapshot()
- switch currentPhase {
- case .preparing, .finished:
- return nil
- case .threadStarted(let threadID):
- return try await sendInterrupt(
- threadID: threadID,
- turnID: "",
- willInterruptActiveTurn: willInterruptActiveTurn
- )
- case .reviewStarted(let turnThreadID, let turnID):
- return try await sendInterrupt(
- threadID: turnThreadID,
- turnID: turnID,
- willInterruptActiveTurn: willInterruptActiveTurn
- )
- }
- }
-
- package func finish() {
- phaseLock.lock()
- defer { phaseLock.unlock() }
- phase = .finished
- }
-
- private func phaseSnapshot() -> Phase {
- phaseLock.lock()
- defer { phaseLock.unlock() }
- return phase
- }
-
- private func sendInterrupt(
- threadID: String,
- turnID: String,
- willInterruptActiveTurn: (@Sendable (AppServerReviewInterruption) async -> Void)?
- ) async throws -> AppServerReviewInterruption {
- do {
- let _: EmptyResponse = try await client.send(AppServerAPI.Turn.Interrupt.Request(
- params: .init(threadID: threadID, turnID: turnID)
- ))
- return .init(threadID: threadID, turnID: turnID)
- } catch {
- guard let activeTurnID = Self.activeTurnID(from: error),
- activeTurnID != turnID
- else {
- throw error
- }
- let activeInterruption = AppServerReviewInterruption(threadID: threadID, turnID: activeTurnID)
- if let willInterruptActiveTurn {
- await willInterruptActiveTurn(activeInterruption)
- }
- let _: EmptyResponse = try await client.send(AppServerAPI.Turn.Interrupt.Request(
- params: .init(threadID: threadID, turnID: activeTurnID)
- ))
- setPhase(.reviewStarted(turnThreadID: threadID, turnID: activeTurnID))
- return activeInterruption
- }
- }
-
- private func setPhase(_ phase: Phase) {
- phaseLock.lock()
- defer { phaseLock.unlock() }
- self.phase = phase
- }
-
- private static func activeTurnID(from error: Error) -> String? {
- guard case JSONRPC.Error.responseError(_, let message) = error,
- let range = message.range(of: " but found ")
- else {
- return nil
- }
- return String(message[range.upperBound...])
- .trimmingCharacters(in: CharacterSet(charactersIn: "` ").union(.whitespacesAndNewlines))
- .nilIfEmpty
- }
-}
diff --git a/Sources/CodexReviewAppServer/CodexReviewTarget+AppServer.swift b/Sources/CodexReviewAppServer/CodexReviewTarget+AppServer.swift
new file mode 100644
index 00000000..38b806df
--- /dev/null
+++ b/Sources/CodexReviewAppServer/CodexReviewTarget+AppServer.swift
@@ -0,0 +1,18 @@
+import CodexAppServerKit
+import CodexReviewKit
+import Foundation
+
+extension CodexReviewAPI.Target {
+ package var appServerReviewTarget: CodexReviewTarget {
+ switch self {
+ case .uncommittedChanges:
+ .uncommittedChanges
+ case .baseBranch(let branch):
+ .baseBranch(branch)
+ case .commit(let sha, let title):
+ .commit(sha: sha, title: title)
+ case .custom(let instructions):
+ .custom(instructions: instructions)
+ }
+ }
+}
diff --git a/Sources/CodexReviewAppServer/JSONRPC.swift b/Sources/CodexReviewAppServer/JSONRPC.swift
deleted file mode 100644
index c7314648..00000000
--- a/Sources/CodexReviewAppServer/JSONRPC.swift
+++ /dev/null
@@ -1,98 +0,0 @@
-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 protocol Transport: Sendable {
- func send(_ request: Request) async throws -> Data
- func notify(_ notification: Notification) async throws
- func notificationStream() async -> AsyncThrowingStream
- func close() async
- }
-
- package enum Error: Swift.Error, Equatable, Sendable, LocalizedError {
- case closed
- case invalidMessage(String)
- case responseError(code: Int, message: String)
-
- 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 message):
- message
- }
- }
- }
-
- package struct Framer: Sendable {
- private var buffer = Data()
-
- package init() {}
-
- package mutating func append(_ data: Data) -> [Data] {
- buffer.append(data)
- return drainLines()
- }
-
- package mutating func finish() -> [Data] {
- guard buffer.isEmpty == false else {
- return []
- }
- defer { buffer.removeAll(keepingCapacity: false) }
- return [buffer]
- }
-
- private mutating func drainLines() -> [Data] {
- var lines: [Data] = []
- while let newline = buffer.firstIndex(of: 0x0A) {
- let line = buffer[.. 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/CodexReviewAppServerWire/AppServerWireEvent.swift b/Sources/CodexReviewAppServerWire/AppServerWireEvent.swift
deleted file mode 100644
index cc5aceb1..00000000
--- a/Sources/CodexReviewAppServerWire/AppServerWireEvent.swift
+++ /dev/null
@@ -1,1472 +0,0 @@
-import Foundation
-import CodexReviewDomain
-
-public struct AppServerWireEvent: Equatable, Sendable {
- public var kind: ReviewWireEventKind
- public var itemKind: ReviewItemKind?
- public var itemID: ReviewTimelineItem.ID?
- public var timestamp: Date?
-
- public init(
- kind: ReviewWireEventKind,
- itemKind: ReviewItemKind? = nil,
- itemID: ReviewTimelineItem.ID? = nil,
- timestamp: Date? = nil
- ) {
- self.kind = kind
- self.itemKind = itemKind
- self.itemID = itemID
- self.timestamp = timestamp
- }
-}
-
-public indirect enum AppServerWireJSONValue: Decodable, Equatable, Sendable {
- case string(String)
- case int(Int)
- case double(Double)
- case bool(Bool)
- case object([String: AppServerWireJSONValue])
- case array([AppServerWireJSONValue])
- case null
-
- public init(from decoder: Decoder) throws {
- let container = try decoder.singleValueContainer()
- if container.decodeNil() {
- self = .null
- } else if let value = try? container.decode(String.self) {
- self = .string(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(Bool.self) {
- self = .bool(value)
- } else if let value = try? container.decode([String: AppServerWireJSONValue].self) {
- self = .object(value)
- } else {
- self = .array(try container.decode([AppServerWireJSONValue].self))
- }
- }
-
- public var objectValue: [String: AppServerWireJSONValue]? {
- if case .object(let value) = self {
- return value
- }
- return nil
- }
-
- public var nonNullText: String? {
- switch self {
- case .null:
- return nil
- case .string(let value):
- return value
- case .int(let value):
- return String(value)
- case .double(let value):
- return String(value)
- case .bool(let value):
- return String(value)
- case .object, .array:
- return jsonString
- }
- }
-
- public var jsonString: String {
- let fallback: String
- switch self {
- case .object:
- fallback = "{}"
- case .array:
- fallback = "[]"
- case .string(let value):
- fallback = value
- case .int(let value):
- fallback = String(value)
- case .double(let value):
- fallback = String(value)
- case .bool(let value):
- fallback = String(value)
- case .null:
- fallback = "null"
- }
- return Self.jsonText(foundationObject, fallback: fallback)
- }
-
- private var foundationObject: Any {
- switch self {
- case .string(let value):
- return value
- case .int(let value):
- return value
- case .double(let value):
- return value
- case .bool(let value):
- return value
- case .object(let value):
- return value.mapValues(\.foundationObject)
- case .array(let value):
- return value.map(\.foundationObject)
- case .null:
- return NSNull()
- }
- }
-
- private static func jsonText(_ object: Any, fallback: String) -> String {
- guard let data = try? JSONSerialization.data(
- withJSONObject: object,
- options: [.fragmentsAllowed, .sortedKeys]
- ),
- let text = String(data: data, encoding: .utf8)
- else {
- return fallback
- }
- return text
- }
-}
-
-public struct AppServerWireReviewNotification: Decodable, Equatable, Sendable {
- public var method: ReviewWireEventKind
- public var payload: Payload
- public var rawPayload: AppServerWireJSONValue?
-
- public var rawMethod: String {
- method.rawValue
- }
-
- public init(
- method: ReviewWireEventKind,
- payload: Payload = Payload(),
- rawPayload: AppServerWireJSONValue? = nil
- ) {
- self.method = method
- self.payload = payload
- self.rawPayload = rawPayload
- }
-
- public enum CodingKeys: String, CodingKey {
- case method
- case payload = "params"
- }
-
- public init(from decoder: Decoder) throws {
- let container = try decoder.container(keyedBy: CodingKeys.self)
- let rawMethod = try container.decode(String.self, forKey: .method)
- self.method = ReviewWireEventKind(rawValue: rawMethod)
- self.rawPayload = container.contains(.payload)
- ? try container.decode(AppServerWireJSONValue.self, forKey: .payload)
- : nil
- if rawPayload?.objectValue != nil,
- let payload = try? container.decodeIfPresent(Payload.self, forKey: .payload) {
- self.payload = payload
- } else {
- self.payload = Payload(rawValue: rawPayload)
- }
- }
-
- public func domainEvents(fallbackReviewThreadID: ReviewThread.ID? = nil) -> [ReviewDomainEvent] {
- switch method {
- case .turnStarted:
- return [.runStarted(
- turnID: ReviewTurn.ID(rawValue: payload.resolvedTurnID ?? ""),
- reviewThreadID: (payload.reviewThreadID ?? payload.threadID).map(ReviewThread.ID.init(rawValue:)) ?? fallbackReviewThreadID,
- model: payload.model
- )]
- case .turnCompleted:
- return payload.turnCompletedEvents()
- case .turnFailed:
- return [.reviewFailed(payload.terminalMessage ?? "")]
- case .turnCancelled, .turnAborted:
- return [.reviewCancelled(payload.terminalMessage ?? "")]
- case .itemStarted:
- return payload.itemStartedEvents(method: method)
- case .itemUpdated:
- return payload.itemUpdateEvents(method: method)
- case .itemCompleted:
- return payload.itemCompletionEvents(method: method)
- case .agentMessageDelta:
- return payload.deltaDomainEvent(
- kind: .agentMessage,
- family: .message,
- content: .message(.init(text: ""))
- )
- case .planDelta:
- return payload.deltaDomainEvent(
- kind: .plan,
- family: .plan,
- content: .plan(.init(markdown: ""))
- )
- case .reasoningSummaryTextDelta:
- return payload.deltaDomainEvent(
- kind: .reasoning,
- family: .reasoning,
- content: .reasoning(.init(text: "", style: .summary)),
- itemID: payload.reasoningSummaryItemID
- )
- case .reasoningTextDelta:
- return payload.deltaDomainEvent(
- kind: .reasoning,
- family: .reasoning,
- content: .reasoning(.init(text: "", style: .raw)),
- itemID: payload.rawReasoningItemID
- )
- case .reasoningSummaryPartAdded:
- return []
- case .autoApprovalReviewStarted, .autoApprovalReviewCompleted:
- return []
- case .commandExecutionOutputDelta, .commandExecOutputDelta, .processOutputDelta:
- return payload.deltaDomainEvent(
- kind: .commandExecution,
- family: .command,
- content: .command(.init(command: payload.item?.command ?? "", cwd: payload.item?.cwd)),
- delta: payload.outputDelta,
- itemID: payload.outputItemID
- )
- case .commandExecutionTerminalInteraction:
- return payload.deltaDomainEvent(
- kind: .commandExecution,
- family: .command,
- content: .command(.init(command: payload.item?.command ?? "", cwd: payload.item?.cwd)),
- delta: payload.stdin
- )
- case .fileChangeOutputDelta:
- return payload.deltaDomainEvent(
- kind: .fileChange,
- family: .fileChange,
- content: .fileChange(.init(title: payload.item?.path ?? "")),
- delta: payload.delta
- )
- case .mcpToolCallProgress:
- return payload.toolProgressEvent(method: method)
- case .fileChangePatchUpdated:
- return payload.fileChangeUpdateEvent(method: method)
- case .turnDiffUpdated:
- return payload.diffUpdateEvent(method: method)
- case .turnPlanUpdated:
- return payload.planUpdateEvent(method: method)
- case .threadCompacted:
- return payload.contextCompactionEvent(method: method)
- case .threadClosed:
- return [.reviewFailed(payload.terminalMessage ?? payload.status?.type ?? "")]
- case .threadStatusChanged:
- return payload.threadStatusEvents(method: method)
- case .error:
- return payload.errorEvents(method: method)
- case .warning, .guardianWarning, .deprecationNotice, .configWarning, .diagnostic:
- return payload.diagnosticEvents(method: method)
- case .modelRerouted:
- return payload.modelReroutedEvents(method: method)
- case .modelVerification:
- return payload.modelVerificationEvents(method: method)
- case .agentMessage:
- return payload.messageEvent(method: method)
- case .log:
- return payload.diagnosticEvents(method: method)
- default:
- return payload.unknownEvent(method: method)
- }
- }
-}
-
-public extension AppServerWireReviewNotification {
- struct Payload: Decodable, Equatable, Sendable {
- public var threadID: String?
- public var turn: Turn?
- public var turnID: String?
- public var reviewThreadID: String?
- public var model: String?
- public var fromModel: String?
- public var toModel: String?
- public var reason: String?
- public var itemID: String?
- public var processID: String?
- public var processHandle: String?
- public var delta: String?
- public var deltaBase64: String?
- public var stdin: String?
- public var message: String?
- public var summary: String?
- public var details: String?
- public var diff: String?
- public var result: AppServerWireJSONValue?
- public var error: ErrorPayload?
- public var willRetry: Bool?
- public var status: Status?
- public var startedAtMs: Int64?
- public var completedAtMs: Int64?
- public var summaryIndex: Int?
- public var contentIndex: Int?
- public var plan: [PlanStep]
- public var verifications: [String]
- public var changes: [FileChange]
- public var item: Item?
- public var rawValue: AppServerWireJSONValue?
-
- public var rawFields: [String: AppServerWireJSONValue] {
- rawValue?.objectValue ?? [:]
- }
-
- public init(
- threadID: String? = nil,
- turn: Turn? = nil,
- turnID: String? = nil,
- reviewThreadID: String? = nil,
- model: String? = nil,
- fromModel: String? = nil,
- toModel: String? = nil,
- reason: String? = nil,
- itemID: String? = nil,
- processID: String? = nil,
- processHandle: String? = nil,
- delta: String? = nil,
- deltaBase64: String? = nil,
- stdin: String? = nil,
- message: String? = nil,
- summary: String? = nil,
- details: String? = nil,
- diff: String? = nil,
- result: AppServerWireJSONValue? = nil,
- error: ErrorPayload? = nil,
- willRetry: Bool? = nil,
- status: Status? = nil,
- startedAtMs: Int64? = nil,
- completedAtMs: Int64? = nil,
- summaryIndex: Int? = nil,
- contentIndex: Int? = nil,
- plan: [PlanStep] = [],
- verifications: [String] = [],
- changes: [FileChange] = [],
- item: Item? = nil,
- rawValue: AppServerWireJSONValue? = nil
- ) {
- self.threadID = threadID
- self.turn = turn
- self.turnID = turnID
- self.reviewThreadID = reviewThreadID
- self.model = model
- self.fromModel = fromModel
- self.toModel = toModel
- self.reason = reason
- self.itemID = itemID
- self.processID = processID
- self.processHandle = processHandle
- self.delta = delta
- self.deltaBase64 = deltaBase64
- self.stdin = stdin
- self.message = message
- self.summary = summary
- self.details = details
- self.diff = diff
- self.result = result
- self.error = error
- self.willRetry = willRetry
- self.status = status
- self.startedAtMs = startedAtMs
- self.completedAtMs = completedAtMs
- self.summaryIndex = summaryIndex
- self.contentIndex = contentIndex
- self.plan = plan
- self.verifications = verifications
- self.changes = changes
- self.item = item
- self.rawValue = rawValue
- }
-
- enum CodingKeys: String, CodingKey {
- case threadID = "threadId"
- case turn
- case turnID = "turnId"
- case reviewThreadID = "reviewThreadId"
- case model
- case fromModel
- case toModel
- case reason
- case itemID = "itemId"
- case processID = "processId"
- case processHandle
- case delta
- case deltaBase64
- case stdin
- case message
- case summary
- case details
- case diff
- case result
- case error
- case willRetry
- case status
- case startedAtMs
- case completedAtMs
- case summaryIndex
- case contentIndex
- case plan
- case verifications
- case changes
- case item
- }
-
- public init(from decoder: Decoder) throws {
- self.rawValue = try? AppServerWireJSONValue(from: decoder)
- let container = try decoder.container(keyedBy: CodingKeys.self)
- self.threadID = try container.decodeStringIfPresent(forKey: .threadID)
- self.turn = try? container.decodeIfPresent(Turn.self, forKey: .turn)
- self.turnID = try container.decodeStringIfPresent(forKey: .turnID)
- self.reviewThreadID = try container.decodeStringIfPresent(forKey: .reviewThreadID)
- self.model = try container.decodeStringIfPresent(forKey: .model)
- self.fromModel = try container.decodeStringIfPresent(forKey: .fromModel)
- self.toModel = try container.decodeStringIfPresent(forKey: .toModel)
- self.reason = try container.decodeStringIfPresent(forKey: .reason)
- self.itemID = try container.decodeStringIfPresent(forKey: .itemID)
- self.processID = try container.decodeStringIfPresent(forKey: .processID)
- self.processHandle = try container.decodeStringIfPresent(forKey: .processHandle)
- self.delta = try container.decodeStringIfPresent(forKey: .delta)
- self.deltaBase64 = try container.decodeStringIfPresent(forKey: .deltaBase64)
- self.stdin = try container.decodeStringIfPresent(forKey: .stdin)
- self.message = try container.decodeStringIfPresent(forKey: .message)
- self.summary = try container.decodeStringIfPresent(forKey: .summary)
- self.details = try container.decodeStringIfPresent(forKey: .details)
- self.diff = try container.decodeStringIfPresent(forKey: .diff)
- self.result = try? container.decodeIfPresent(AppServerWireJSONValue.self, forKey: .result)
- self.error = try? container.decodeIfPresent(ErrorPayload.self, forKey: .error)
- self.willRetry = try? container.decodeIfPresent(Bool.self, forKey: .willRetry)
- self.status = try? container.decodeIfPresent(Status.self, forKey: .status)
- self.startedAtMs = try? container.decodeIfPresent(Int64.self, forKey: .startedAtMs)
- self.completedAtMs = try? container.decodeIfPresent(Int64.self, forKey: .completedAtMs)
- self.summaryIndex = try? container.decodeIfPresent(Int.self, forKey: .summaryIndex)
- self.contentIndex = try? container.decodeIfPresent(Int.self, forKey: .contentIndex)
- self.plan = (try? container.decodeIfPresent([PlanStep].self, forKey: .plan)) ?? []
- self.verifications = (try? container.decodeIfPresent([String].self, forKey: .verifications)) ?? []
- self.changes = (try? container.decodeIfPresent([FileChange].self, forKey: .changes)) ?? []
- self.item = try? container.decodeIfPresent(Item.self, forKey: .item)
- }
-
- var resolvedTurnID: String? {
- turn?.id.nilIfEmpty ?? turnID
- }
-
- var terminalMessage: String? {
- turn?.error?.message?.nilIfEmpty
- ?? error?.message?.nilIfEmpty
- ?? message?.nilIfEmpty
- ?? summary?.nilIfEmpty
- }
-
- var diagnosticMessage: String? {
- if let message = message?.nilIfEmpty {
- return message
- }
- if let error = error?.message?.nilIfEmpty {
- return error
- }
- if let summary = summary?.nilIfEmpty,
- let details = details?.nilIfEmpty {
- return "\(summary)\n\(details)"
- }
- return summary?.nilIfEmpty ?? details?.nilIfEmpty
- }
-
- var outputDelta: String? {
- delta?.nilIfEmpty ?? decodedBase64Output?.nilIfEmpty
- }
-
- var outputItemID: String? {
- itemID ?? processID ?? processHandle ?? item?.id.nilIfEmpty ?? item?.processID
- }
-
- var reasoningSummaryItemID: String? {
- itemID.map { Self.reasoningSummaryItemID(itemID: $0, summaryIndex: summaryIndex ?? 0) }
- }
-
- var rawReasoningItemID: String? {
- itemID.map { Self.rawReasoningItemID(itemID: $0, contentIndex: contentIndex ?? 0) }
- }
-
- var decodedBase64Output: String? {
- guard let deltaBase64,
- let data = Data(base64Encoded: deltaBase64)
- else {
- return nil
- }
- return String(decoding: data, as: UTF8.self)
- }
-
- func turnCompletedEvents() -> [ReviewDomainEvent] {
- switch terminalDisposition {
- case .failed:
- return [.reviewFailed(terminalMessage ?? "")]
- case .cancelled:
- return [.reviewCancelled(terminalMessage ?? "")]
- case .completed:
- return [.reviewCompleted(summary: message ?? summary ?? "", result: result?.nonNullText)]
- }
- }
-
- func itemStartedEvents(method: ReviewWireEventKind) -> [ReviewDomainEvent] {
- guard let item else {
- return []
- }
- let phase = item.phase(default: .running)
- if item.family == .reasoning {
- let reasoningSeeds = reasoningPartSeeds(for: item, phase: phase)
- if reasoningSeeds.isEmpty == false {
- return reasoningSeeds.map(ReviewDomainEvent.itemStarted)
- }
- guard item.hasReasoningParentContent(fallbackDelta: delta) else {
- return []
- }
- }
- return [.itemStarted(seed(for: item, phase: phase))]
- }
-
- func itemUpdateEvents(method: ReviewWireEventKind) -> [ReviewDomainEvent] {
- if let item {
- let phase = item.phase(default: .running)
- if item.family == .reasoning {
- let reasoningSeeds = reasoningPartSeeds(for: item, phase: phase)
- if reasoningSeeds.isEmpty == false {
- return reasoningSeeds.map(ReviewDomainEvent.itemUpdated)
- }
- guard item.hasReasoningParentContent(fallbackDelta: delta)
- || item.hasReasoningLifecycleUpdate(phase: phase)
- else {
- return []
- }
- }
- return [.itemUpdated(seed(for: item, phase: phase))]
- }
- return unknownEvent(method: method)
- }
-
- func itemCompletionEvents(method: ReviewWireEventKind) -> [ReviewDomainEvent] {
- guard let item else {
- return []
- }
- let phase = item.phase(default: .completed)
- if item.family == .reasoning {
- let reasoningSeeds = reasoningPartSeeds(for: item, phase: phase)
- if reasoningSeeds.isEmpty == false {
- return reasoningSeeds.map(ReviewDomainEvent.itemCompleted)
- }
- }
- if item.wouldEraseStreamedCommandOutput(
- fallbackDelta: delta,
- phase: phase,
- hasCompletionMetadata: completedAt != nil
- ) {
- return []
- }
- return [.itemCompleted(seed(for: item, phase: phase))]
- }
-
- func deltaDomainEvent(
- kind: ReviewItemKind,
- family: ReviewItemFamily,
- content: ReviewTimelineItem.Content,
- delta explicitDelta: String? = nil,
- itemID explicitItemID: String? = nil
- ) -> [ReviewDomainEvent] {
- guard let delta = explicitDelta ?? delta,
- delta.isEmpty == false
- else {
- return []
- }
- return [.textDelta(
- itemID: .init(rawValue: explicitItemID ?? itemID ?? syntheticItemID(method: kind.rawValue)),
- kind: kind,
- family: family,
- content: content,
- delta: delta
- )]
- }
-
- func toolProgressEvent(method: ReviewWireEventKind) -> [ReviewDomainEvent] {
- guard let message = message?.nilIfEmpty else {
- return unknownEvent(method: method)
- }
- return [.itemUpdated(ReviewTimelineItemSeed(
- id: .init(rawValue: itemID.map { "\($0):progress" } ?? syntheticItemID(method: method.rawValue)),
- kind: .mcpToolCall,
- family: .tool,
- phase: .running,
- content: .toolCall(.init(
- server: item?.server,
- tool: item?.tool,
- progress: message
- ))
- ))]
- }
-
- func fileChangeUpdateEvent(method: ReviewWireEventKind) -> [ReviewDomainEvent] {
- let changesOutput = changes.map(\.summaryText).joined(separator: "\n").nilIfEmpty
- let changePath = changes.compactMap { $0.path?.nilIfEmpty }.first
- let updateItemID = item?.path?.nilIfEmpty == nil
- ? itemID.map { "\($0):patch" }
- : item?.id.nilIfEmpty ?? itemID
- return [.itemUpdated(ReviewTimelineItemSeed(
- id: .init(rawValue: updateItemID ?? syntheticItemID(method: method.rawValue)),
- kind: ReviewItemKind(rawValue: method.rawValue),
- family: .fileChange,
- phase: item?.phase(default: .running) ?? .running,
- content: .fileChange(.init(title: item?.path ?? changePath ?? "", output: message ?? delta ?? diff ?? changesOutput ?? ""))
- ))]
- }
-
- func diffUpdateEvent(method: ReviewWireEventKind) -> [ReviewDomainEvent] {
- guard let diff = diff?.nilIfEmpty else {
- return unknownEvent(method: method)
- }
- return [.itemUpdated(ReviewTimelineItemSeed(
- id: .init(rawValue: itemID ?? syntheticItemID(method: method.rawValue)),
- kind: ReviewItemKind(rawValue: method.rawValue),
- family: .fileChange,
- phase: .running,
- content: .fileChange(.init(title: "", output: diff))
- ))]
- }
-
- func planUpdateEvent(method: ReviewWireEventKind) -> [ReviewDomainEvent] {
- let markdown = plan.compactMap { step -> String? in
- switch (step.status.nilIfEmpty, step.step.nilIfEmpty) {
- case let (status?, step?):
- return "[\(status)] \(step)"
- case let (status?, nil):
- return "[\(status)]"
- case let (nil, step?):
- return step
- case (nil, nil):
- return nil
- }
- }.joined(separator: "\n")
- guard markdown.isEmpty == false else {
- return unknownEvent(method: method)
- }
- return [.itemUpdated(ReviewTimelineItemSeed(
- id: .init(rawValue: itemID ?? syntheticItemID(method: method.rawValue)),
- kind: .plan,
- family: .plan,
- phase: .running,
- content: .plan(.init(markdown: markdown))
- ))]
- }
-
- func contextCompactionEvent(method: ReviewWireEventKind) -> [ReviewDomainEvent] {
- [.itemCompleted(ReviewTimelineItemSeed(
- id: .init(rawValue: itemID ?? resolvedTurnID.map { "contextCompaction:\($0)" } ?? syntheticItemID(method: method.rawValue)),
- kind: .contextCompaction,
- family: .contextCompaction,
- phase: .completed,
- content: .contextCompaction(.init(title: status?.type ?? ""))
- ))]
- }
-
- func threadStatusEvents(method: ReviewWireEventKind) -> [ReviewDomainEvent] {
- switch normalizedStatus(status?.type) {
- case "notloaded", "closed":
- return [.reviewFailed(terminalMessage ?? status?.type ?? "")]
- case "cancelled", "canceled", "interrupted", "aborted":
- return [.reviewCancelled(terminalMessage ?? status?.type ?? "")]
- case "systemerror":
- return [.itemUpdated(diagnosticSeed(
- method: method,
- message: terminalMessage ?? status?.type ?? "",
- phase: .running
- ))]
- default:
- return unknownEvent(method: method)
- }
- }
-
- func errorEvents(method: ReviewWireEventKind) -> [ReviewDomainEvent] {
- guard let message = diagnosticMessage else {
- return [.reviewFailed("")]
- }
- let diagnostic = diagnosticSeed(method: method, message: message, phase: willRetry == true ? .running : .failed)
- if willRetry == true {
- return [.itemUpdated(diagnostic)]
- }
- return [.itemUpdated(diagnostic), .reviewFailed(message)]
- }
-
- func diagnosticEvents(method: ReviewWireEventKind) -> [ReviewDomainEvent] {
- guard let message = diagnosticMessage else {
- return unknownEvent(method: method)
- }
- return [.itemUpdated(diagnosticSeed(method: method, message: message, phase: .running))]
- }
-
- func modelReroutedEvents(method: ReviewWireEventKind) -> [ReviewDomainEvent] {
- let route = [fromModel?.nilIfEmpty, toModel?.nilIfEmpty].compactMap(\.self).joined(separator: " -> ")
- let message = [route.nilIfEmpty, reason?.nilIfEmpty].compactMap(\.self).joined(separator: "\n")
- guard message.isEmpty == false else {
- return unknownEvent(method: method)
- }
- return [.itemUpdated(diagnosticSeed(method: method, message: message, phase: .running))]
- }
-
- func modelVerificationEvents(method: ReviewWireEventKind) -> [ReviewDomainEvent] {
- let message = diagnosticMessage ?? verifications.joined(separator: "\n").nilIfEmpty
- guard let message else {
- return unknownEvent(method: method)
- }
- return [.itemUpdated(diagnosticSeed(method: method, message: message, phase: .running))]
- }
-
- func messageEvent(method: ReviewWireEventKind) -> [ReviewDomainEvent] {
- guard let message = message?.nilIfEmpty else {
- return unknownEvent(method: method)
- }
- return [.itemUpdated(ReviewTimelineItemSeed(
- id: .init(rawValue: itemID ?? syntheticItemID(method: method.rawValue)),
- kind: .agentMessage,
- family: .message,
- phase: .completed,
- content: .message(.init(text: message))
- ))]
- }
-
- func unknownEvent(method: ReviewWireEventKind) -> [ReviewDomainEvent] {
- [.itemUpdated(ReviewTimelineItemSeed(
- id: .init(rawValue: itemID ?? syntheticItemID(method: method.rawValue)),
- kind: ReviewItemKind(rawValue: method.rawValue),
- family: .unknown,
- phase: .running,
- content: .unknown(.init(title: method.rawValue, detail: rawValue?.jsonString))
- ))]
- }
-
- func seed(
- for item: Item,
- phase: ReviewItemPhase,
- content explicitContent: ReviewTimelineItem.Content? = nil
- ) -> ReviewTimelineItemSeed {
- ReviewTimelineItemSeed(
- id: .init(rawValue: item.id.nilIfEmpty ?? itemID ?? syntheticItemID(method: item.type.rawValue)),
- kind: item.type,
- family: item.family,
- phase: phase,
- content: explicitContent ?? item.content(fallbackDelta: delta),
- startedAt: startedAt,
- completedAt: completedAt,
- durationMs: item.durationMs
- )
- }
-
- private func reasoningPartSeeds(for item: Item, phase: ReviewItemPhase) -> [ReviewTimelineItemSeed] {
- guard item.family == .reasoning else {
- return []
- }
- let parentItemID = item.id.nilIfEmpty ?? itemID ?? syntheticItemID(method: item.type.rawValue)
- let summarySeeds = item.indexedSummaryTexts.map { index, text in
- reasoningSeed(
- id: Self.reasoningSummaryItemID(itemID: parentItemID, summaryIndex: index),
- text: text,
- style: .summary,
- item: item,
- phase: phase
- )
- }
- let rawSeeds = item.indexedContentTexts.map { index, text in
- reasoningSeed(
- id: Self.rawReasoningItemID(itemID: parentItemID, contentIndex: index),
- text: text,
- style: .raw,
- item: item,
- phase: phase
- )
- }
- return summarySeeds + rawSeeds
- }
-
- private func reasoningSeed(
- id: String,
- text: String,
- style: ReviewTimelineItem.Reasoning.Style,
- item: Item,
- phase: ReviewItemPhase
- ) -> ReviewTimelineItemSeed {
- ReviewTimelineItemSeed(
- id: .init(rawValue: id),
- kind: item.type,
- family: .reasoning,
- phase: phase,
- content: .reasoning(.init(text: text, style: style)),
- startedAt: startedAt,
- completedAt: completedAt,
- durationMs: item.durationMs
- )
- }
-
- private var terminalDisposition: TerminalDisposition {
- switch normalizedStatus(turn?.status ?? status?.type) {
- case "failed", "failure", "error", "errored":
- return .failed
- case "cancelled", "canceled", "interrupted", "aborted":
- return .cancelled
- default:
- return .completed
- }
- }
-
- private var startedAt: Date? {
- startedAtMs.map(Self.date(millisecondsSince1970:))
- }
-
- private var completedAt: Date? {
- completedAtMs.map(Self.date(millisecondsSince1970:))
- }
-
- private func diagnosticSeed(
- method: ReviewWireEventKind,
- message: String,
- phase: ReviewItemPhase
- ) -> ReviewTimelineItemSeed {
- ReviewTimelineItemSeed(
- id: .init(rawValue: itemID ?? syntheticItemID(method: method.rawValue)),
- kind: ReviewItemKind(rawValue: method.rawValue),
- family: .diagnostic,
- phase: phase,
- content: .diagnostic(.init(message: message))
- )
- }
-
- private func syntheticItemID(method: String) -> String {
- [resolvedTurnID, method].compactMap(\.self).joined(separator: ":").nilIfEmpty ?? method
- }
-
- private static func reasoningSummaryItemID(itemID: String, summaryIndex: Int) -> String {
- "\(itemID):summary:\(summaryIndex)"
- }
-
- private static func rawReasoningItemID(itemID: String, contentIndex: Int) -> String {
- "\(itemID):content:\(contentIndex)"
- }
-
- private static func date(millisecondsSince1970 milliseconds: Int64) -> Date {
- Date(timeIntervalSince1970: TimeInterval(milliseconds) / 1000)
- }
- }
-
- struct Turn: Decodable, Equatable, Sendable {
- public var id: String
- public var status: String?
- public var error: ErrorPayload?
- public var rawValue: AppServerWireJSONValue?
-
- public var rawFields: [String: AppServerWireJSONValue] {
- rawValue?.objectValue ?? [:]
- }
-
- public init(id: String, status: String? = nil, error: ErrorPayload? = nil, rawValue: AppServerWireJSONValue? = nil) {
- self.id = id
- self.status = status
- self.error = error
- self.rawValue = rawValue
- }
-
- enum CodingKeys: String, CodingKey {
- case id
- case status
- case error
- }
-
- public init(from decoder: Decoder) throws {
- self.rawValue = try? AppServerWireJSONValue(from: decoder)
- let container = try decoder.container(keyedBy: CodingKeys.self)
- self.id = try container.decodeStringIfPresent(forKey: .id) ?? ""
- self.status = try container.decodeStringIfPresent(forKey: .status)
- self.error = try? container.decodeIfPresent(ErrorPayload.self, forKey: .error)
- }
- }
-
- struct ErrorPayload: Decodable, Equatable, Sendable {
- public var message: String?
- public var rawValue: AppServerWireJSONValue?
-
- public init(message: String? = nil, rawValue: AppServerWireJSONValue? = nil) {
- self.message = message
- self.rawValue = rawValue
- }
-
- enum CodingKeys: String, CodingKey {
- case message
- }
-
- public init(from decoder: Decoder) throws {
- self.rawValue = try? AppServerWireJSONValue(from: decoder)
- let singleValue = try decoder.singleValueContainer()
- if singleValue.decodeNil() {
- self.message = nil
- } else if let value = try? singleValue.decode(String.self) {
- self.message = value
- } else if let container = try? decoder.container(keyedBy: CodingKeys.self) {
- self.message = try container.decodeStringIfPresent(forKey: .message)
- } else {
- self.message = nil
- }
- }
- }
-
- struct Status: Decodable, Equatable, Sendable {
- public var type: String
- public var rawValue: AppServerWireJSONValue?
-
- public init(type: String, rawValue: AppServerWireJSONValue? = nil) {
- self.type = type
- self.rawValue = rawValue
- }
-
- enum CodingKeys: String, CodingKey {
- case type
- }
-
- public init(from decoder: Decoder) throws {
- self.rawValue = try? AppServerWireJSONValue(from: decoder)
- let container = try decoder.container(keyedBy: CodingKeys.self)
- self.type = try container.decodeStringIfPresent(forKey: .type) ?? ""
- }
- }
-
- struct PlanStep: Decodable, Equatable, Sendable {
- public var step: String
- public var status: String
-
- public init(step: String, status: String) {
- self.step = step
- self.status = status
- }
- }
-
- struct FileChange: Decodable, Equatable, Sendable {
- public var path: String?
- public var kind: String?
- public var diff: String?
-
- public var summaryText: String {
- [kind, path, diff].compactMap { $0?.nilIfEmpty }.joined(separator: "\n")
- }
-
- public init(path: String? = nil, kind: String? = nil, diff: String? = nil) {
- self.path = path
- self.kind = kind
- self.diff = diff
- }
-
- enum CodingKeys: String, CodingKey {
- case path
- case kind
- case diff
- }
-
- public init(from decoder: Decoder) throws {
- let container = try decoder.container(keyedBy: CodingKeys.self)
- self.path = try container.decodeStringIfPresent(forKey: .path)
- self.kind = try container.decodeStringIfPresent(forKey: .kind)
- self.diff = try container.decodeStringIfPresent(forKey: .diff)
- }
- }
-
- struct CommandAction: Decodable, Equatable, Sendable {
- public var kind: ReviewCommandActionKind
- public var command: String?
- public var name: String?
- public var path: String?
- public var query: String?
-
- public init(
- kind: ReviewCommandActionKind,
- 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
- }
-
- enum CodingKeys: String, CodingKey {
- case type
- case kind
- case command
- case name
- case path
- case query
- }
-
- public init(from decoder: Decoder) throws {
- let container = try decoder.container(keyedBy: CodingKeys.self)
- let rawKind = try container.decodeStringIfPresent(forKey: .type)
- ?? container.decodeStringIfPresent(forKey: .kind)
- ?? ReviewCommandActionKind.unknown.rawValue
- self.kind = .init(rawValue: rawKind)
- self.command = try container.decodeStringIfPresent(forKey: .command)
- self.name = try container.decodeStringIfPresent(forKey: .name)
- self.path = try container.decodeStringIfPresent(forKey: .path)
- self.query = try container.decodeStringIfPresent(forKey: .query)
- }
-
- var timelineAction: ReviewTimelineItem.CommandAction {
- .init(
- kind: kind,
- command: command,
- name: name,
- path: path,
- query: query
- )
- }
- }
-
- struct Item: Decodable, Equatable, Sendable {
- public var id: String
- public var type: ReviewItemKind
- public var text: String?
- public var command: String?
- public var cwd: String?
- public var processID: String?
- public var source: String?
- public var aggregatedOutput: String?
- public var hasAggregatedOutputField: Bool
- public var exitCode: Int?
- public var durationMs: Int?
- public var commandActions: [CommandAction]
- public var status: String?
- public var server: String?
- public var tool: String?
- public var namespace: String?
- public var query: String?
- public var path: String?
- public var review: String?
- public var prompt: String?
- public var summary: [String]
- public var summaryFragments: [TextFragment]
- public var content: [String]
- public var contentFragments: [TextFragment]
- public var fragments: [TextFragment]
- public var arguments: AppServerWireJSONValue?
- public var input: AppServerWireJSONValue?
- public var result: AppServerWireJSONValue?
- public var error: AppServerWireJSONValue?
- public var success: Bool?
- public var rawValue: AppServerWireJSONValue?
-
- public var rawType: String {
- type.rawValue
- }
-
- public var rawFields: [String: AppServerWireJSONValue] {
- rawValue?.objectValue ?? [:]
- }
-
- public init(
- id: String,
- type: ReviewItemKind,
- text: String? = nil,
- command: String? = nil,
- cwd: String? = nil,
- processID: String? = nil,
- source: String? = nil,
- aggregatedOutput: String? = nil,
- hasAggregatedOutputField: Bool = false,
- exitCode: Int? = nil,
- durationMs: Int? = nil,
- commandActions: [CommandAction] = [],
- status: String? = nil,
- server: String? = nil,
- tool: String? = nil,
- namespace: String? = nil,
- query: String? = nil,
- path: String? = nil,
- review: String? = nil,
- prompt: String? = nil,
- summary: [String] = [],
- summaryFragments: [TextFragment] = [],
- content: [String] = [],
- contentFragments: [TextFragment] = [],
- fragments: [TextFragment] = [],
- arguments: AppServerWireJSONValue? = nil,
- input: AppServerWireJSONValue? = nil,
- result: AppServerWireJSONValue? = nil,
- error: AppServerWireJSONValue? = nil,
- success: Bool? = nil,
- rawValue: AppServerWireJSONValue? = nil
- ) {
- self.id = id
- self.type = type
- self.text = text
- self.command = command
- self.cwd = cwd
- self.processID = processID
- self.source = source
- self.aggregatedOutput = aggregatedOutput
- self.hasAggregatedOutputField = hasAggregatedOutputField
- self.exitCode = exitCode
- self.durationMs = durationMs
- self.commandActions = commandActions
- self.status = status
- self.server = server
- self.tool = tool
- self.namespace = namespace
- self.query = query
- self.path = path
- self.review = review
- self.prompt = prompt
- self.summary = summary
- self.summaryFragments = summaryFragments
- self.content = content
- self.contentFragments = contentFragments
- self.fragments = fragments
- self.arguments = arguments
- self.input = input
- self.result = result
- self.error = error
- self.success = success
- self.rawValue = rawValue
- }
-
- enum CodingKeys: String, CodingKey {
- case id
- case type
- case text
- case command
- case cwd
- case processID = "processId"
- case source
- case aggregatedOutput
- case exitCode
- case durationMs
- case commandActions
- case status
- case server
- case tool
- case namespace
- case query
- case path
- case review
- case prompt
- case summary
- case content
- case fragments
- case arguments
- case input
- case result
- case error
- case success
- }
-
- public init(from decoder: Decoder) throws {
- self.rawValue = try? AppServerWireJSONValue(from: decoder)
- let container = try decoder.container(keyedBy: CodingKeys.self)
- self.id = try container.decodeStringIfPresent(forKey: .id) ?? ""
- self.type = ReviewItemKind(rawValue: try container.decodeStringIfPresent(forKey: .type) ?? "unknown")
- self.text = try container.decodeStringIfPresent(forKey: .text)
- self.command = try container.decodeStringIfPresent(forKey: .command)
- self.cwd = try container.decodeStringIfPresent(forKey: .cwd)
- self.processID = try container.decodeStringIfPresent(forKey: .processID)
- self.source = try container.decodeStringIfPresent(forKey: .source)
- self.hasAggregatedOutputField = container.contains(.aggregatedOutput)
- self.aggregatedOutput = try container.decodeStringIfPresent(forKey: .aggregatedOutput)?.nilIfEmpty
- self.exitCode = try? container.decodeIfPresent(Int.self, forKey: .exitCode)
- self.durationMs = try? container.decodeIfPresent(Int.self, forKey: .durationMs)
- self.commandActions = (try? container.decodeIfPresent([CommandAction].self, forKey: .commandActions)) ?? []
- self.status = try container.decodeStringIfPresent(forKey: .status)
- self.server = try container.decodeStringIfPresent(forKey: .server)
- self.tool = try container.decodeStringIfPresent(forKey: .tool)
- self.namespace = try container.decodeStringIfPresent(forKey: .namespace)
- self.query = try container.decodeStringIfPresent(forKey: .query)
- self.path = try container.decodeStringIfPresent(forKey: .path)
- self.review = try container.decodeStringIfPresent(forKey: .review)
- self.prompt = try container.decodeStringIfPresent(forKey: .prompt)
- self.summary = (try? container.decodeIfPresent([String].self, forKey: .summary)) ?? []
- self.summaryFragments = (try? container.decodeIfPresent([TextFragment].self, forKey: .summary)) ?? []
- self.content = (try? container.decodeIfPresent([String].self, forKey: .content)) ?? []
- self.contentFragments = (try? container.decodeIfPresent([TextFragment].self, forKey: .content)) ?? []
- self.fragments = (try? container.decodeIfPresent([TextFragment].self, forKey: .fragments)) ?? []
- self.arguments = try? container.decodeIfPresent(AppServerWireJSONValue.self, forKey: .arguments)
- self.input = try? container.decodeIfPresent(AppServerWireJSONValue.self, forKey: .input)
- self.result = try? container.decodeIfPresent(AppServerWireJSONValue.self, forKey: .result)
- self.error = try? container.decodeIfPresent(AppServerWireJSONValue.self, forKey: .error)
- self.success = try? container.decodeIfPresent(Bool.self, forKey: .success)
- }
-
- var family: ReviewItemFamily {
- switch type.rawValue {
- case ReviewItemKind.agentMessage.rawValue,
- "userMessage",
- "exitedReviewMode":
- return .message
- case ReviewItemKind.commandExecution.rawValue:
- return .command
- case ReviewItemKind.fileChange.rawValue:
- return .fileChange
- case ReviewItemKind.plan.rawValue:
- return .plan
- case ReviewItemKind.reasoning.rawValue:
- return .reasoning
- case ReviewItemKind.contextCompaction.rawValue:
- return .contextCompaction
- case ReviewItemKind.webSearch.rawValue:
- return .search
- case ReviewItemKind.mcpToolCall.rawValue,
- ReviewItemKind.dynamicToolCall.rawValue,
- "collabAgentToolCall",
- ReviewItemKind.imageGeneration.rawValue,
- ReviewItemKind.imageView.rawValue:
- return .tool
- case "hookPrompt", "autoApprovalReview":
- return .approval
- case "enteredReviewMode":
- return .lifecycle
- case "diagnostic", "warning":
- return .diagnostic
- default:
- return .unknown
- }
- }
-
- func phase(default defaultPhase: ReviewItemPhase) -> ReviewItemPhase {
- if let status = normalizedStatus(status) {
- switch status {
- case "approved", "completed", "succeeded", "success":
- return .completed
- case "cancelled", "canceled", "interrupted", "aborted":
- return .cancelled
- case "failed", "failure", "error", "errored":
- return .failed
- case "incomplete":
- return .incomplete
- case "skipped":
- return .skipped
- case "approval", "awaitingapproval", "pendingapproval":
- return .awaitingApproval
- case "queued", "pending":
- return .queued
- case "waiting", "waitingforinput", "inputrequired":
- return .waitingForInput
- case "inprogress", "running", "started":
- return .running
- default:
- break
- }
- }
- if error?.nonNullText?.nilIfEmpty != nil || success == false {
- return .failed
- }
- if success == true {
- return .completed
- }
- if let exitCode {
- return exitCode == 0 ? .completed : .failed
- }
- return defaultPhase
- }
-
- func content(fallbackDelta rawFallbackDelta: String?) -> ReviewTimelineItem.Content {
- let fallbackDelta = rawFallbackDelta?.nilIfEmpty
- switch family {
- case .message:
- return .message(.init(text: text ?? review ?? joinedContentText ?? fallbackDelta ?? ""))
- case .command:
- return .command(.init(
- command: command ?? "",
- cwd: cwd,
- output: aggregatedOutput ?? fallbackDelta ?? "",
- exitCode: exitCode,
- actions: commandActions.map(\.timelineAction)
- ))
- case .fileChange:
- return .fileChange(.init(title: path ?? "", output: aggregatedOutput ?? text ?? fallbackDelta ?? ""))
- case .plan:
- return .plan(.init(markdown: text ?? fallbackDelta ?? ""))
- case .reasoning:
- let summaryText = summary.joined(separator: "\n").nilIfEmpty
- let contentText = content.joined(separator: "\n").nilIfEmpty
- let style: ReviewTimelineItem.Reasoning.Style = summaryText == nil ? .raw : .summary
- return .reasoning(.init(text: text ?? summaryText ?? contentText ?? fallbackDelta ?? "", style: style))
- case .contextCompaction:
- return .contextCompaction(.init(title: status ?? text ?? ""))
- case .search:
- return .search(.init(query: query ?? text ?? "", result: result?.nonNullText))
- case .tool:
- return .toolCall(.init(
- namespace: namespace,
- server: server,
- tool: tool,
- arguments: arguments?.nonNullText ?? input?.nonNullText,
- result: result?.nonNullText,
- error: error?.nonNullText
- ))
- case .approval:
- return .approval(.init(title: prompt ?? text ?? joinedFragmentText ?? "", detail: review))
- case .diagnostic:
- return .diagnostic(.init(message: text ?? error?.nonNullText ?? fallbackDelta ?? ""))
- case .lifecycle, .unknown:
- return .unknown(.init(title: type.rawValue, detail: rawValue?.jsonString))
- }
- }
-
- func wouldEraseStreamedCommandOutput(
- fallbackDelta: String?,
- phase: ReviewItemPhase,
- hasCompletionMetadata: Bool
- ) -> Bool {
- family == .command
- && aggregatedOutput == nil
- && hasAggregatedOutputField == false
- && fallbackDelta?.nilIfEmpty == nil
- && phase == .completed
- && hasCompletionMetadata == false
- && hasCommandCompletionSnapshot == false
- }
-
- func hasReasoningParentContent(fallbackDelta: String?) -> Bool {
- family == .reasoning
- && (text?.nilIfEmpty != nil || fallbackDelta?.nilIfEmpty != nil)
- }
-
- func hasReasoningLifecycleUpdate(phase: ReviewItemPhase) -> Bool {
- family == .reasoning
- && phase.isTerminal
- && (
- status?.nilIfEmpty != nil
- || success != nil
- || error?.nonNullText?.nilIfEmpty != nil
- )
- }
-
- var hasCommandCompletionSnapshot: Bool {
- exitCode != nil
- || durationMs != nil
- || status?.nilIfEmpty != nil
- || success != nil
- || command?.nilIfEmpty != nil
- || cwd?.nilIfEmpty != nil
- || processID?.nilIfEmpty != nil
- || source?.nilIfEmpty != nil
- }
-
- private var joinedContentText: String? {
- indexedContentTexts
- .map { $0.1 }
- .joined(separator: "\n")
- .nilIfEmpty
- }
-
- fileprivate var indexedSummaryTexts: [(Int, String)] {
- let strings = summary.enumerated().compactMap { index, text in
- text.nilIfEmpty.map { (index, $0) }
- }
- if strings.isEmpty == false {
- return strings
- }
- return summaryFragments.enumerated().compactMap { index, fragment in
- fragment.text?.nilIfEmpty.map { (index, $0) }
- }
- }
-
- fileprivate var indexedContentTexts: [(Int, String)] {
- let strings = content.enumerated().compactMap { index, text in
- text.nilIfEmpty.map { (index, $0) }
- }
- if strings.isEmpty == false {
- return strings
- }
- return contentFragments.enumerated().compactMap { index, fragment in
- fragment.text?.nilIfEmpty.map { (index, $0) }
- }
- }
-
- private var joinedFragmentText: String? {
- fragments.compactMap { $0.text?.nilIfEmpty }
- .joined(separator: "\n")
- .nilIfEmpty
- }
- }
-
- struct TextFragment: Decodable, Equatable, Sendable {
- public var text: String?
-
- public init(text: String? = nil) {
- self.text = text
- }
-
- enum CodingKeys: String, CodingKey {
- case text
- }
-
- public init(from decoder: Decoder) throws {
- let singleValueContainer = try decoder.singleValueContainer()
- if singleValueContainer.decodeNil() {
- self.text = nil
- return
- }
- if let text = try? singleValueContainer.decode(String.self) {
- self.text = text
- return
- }
- let container = try decoder.container(keyedBy: CodingKeys.self)
- self.text = try container.decodeStringIfPresent(forKey: .text)
- }
- }
-}
-
-private enum TerminalDisposition {
- case completed
- case failed
- case cancelled
-}
-
-private extension ReviewWireEventKind {
- static let turnCompleted: Self = "turn/completed"
- static let turnFailed: Self = "turn/failed"
- static let turnCancelled: Self = "turn/cancelled"
- static let turnAborted: Self = "turn/aborted"
- static let turnDiffUpdated: Self = "turn/diff/updated"
- static let turnPlanUpdated: Self = "turn/plan/updated"
- static let reasoningSummaryPartAdded: Self = "item/reasoning/summaryPartAdded"
- static let autoApprovalReviewStarted: Self = "item/autoApprovalReview/started"
- static let autoApprovalReviewCompleted: Self = "item/autoApprovalReview/completed"
- static let itemUpdated: Self = "item/updated"
- static let commandExecOutputDelta: Self = "command/exec/outputDelta"
- static let processOutputDelta: Self = "process/outputDelta"
- static let commandExecutionTerminalInteraction: Self = "item/commandExecution/terminalInteraction"
- static let fileChangePatchUpdated: Self = "item/fileChange/patchUpdated"
- static let agentMessage: Self = "agent/message"
- static let log: Self = "log"
- static let error: Self = "error"
- static let warning: Self = "warning"
- static let guardianWarning: Self = "guardianWarning"
- static let deprecationNotice: Self = "deprecationNotice"
- static let configWarning: Self = "configWarning"
- static let diagnostic: Self = "diagnostic"
- static let modelRerouted: Self = "model/rerouted"
- static let modelVerification: Self = "model/verification"
- static let threadCompacted: Self = "thread/compacted"
- static let threadClosed: Self = "thread/closed"
- static let threadStatusChanged: Self = "thread/status/changed"
-}
-
-private extension KeyedDecodingContainer {
- func decodeStringIfPresent(forKey key: Key) throws -> String? {
- if let value = try? decodeIfPresent(String.self, forKey: key) {
- return value
- }
- return nil
- }
-}
-
-private extension String {
- var nilIfEmpty: String? {
- isEmpty ? nil : self
- }
-}
-
-private func normalizedStatus(_ value: String?) -> String? {
- value?
- .trimmingCharacters(in: .whitespacesAndNewlines)
- .lowercased()
- .replacingOccurrences(of: "_", with: "")
- .replacingOccurrences(of: "-", with: "")
- .nilIfEmpty
-}
diff --git a/Sources/CodexReviewApplication/ReviewStore.swift b/Sources/CodexReviewApplication/ReviewStore.swift
deleted file mode 100644
index 7a839e68..00000000
--- a/Sources/CodexReviewApplication/ReviewStore.swift
+++ /dev/null
@@ -1,36 +0,0 @@
-import Foundation
-import Observation
-import CodexReviewDomain
-
-@MainActor
-@Observable
-public final class ReviewStore {
- public private(set) var orderedJobIDs: [ReviewJob.ID] = []
- public private(set) var jobsByID: [ReviewJob.ID: ReviewJob] = [:]
-
- public init() {}
-
- public var jobs: [ReviewJob] {
- orderedJobIDs.compactMap { jobsByID[$0] }
- }
-
- @discardableResult
- public func upsertJob(id: ReviewJob.ID) -> ReviewJob {
- if let job = jobsByID[id] {
- return job
- }
- let job = ReviewJob(id: id)
- jobsByID[id] = job
- orderedJobIDs.append(id)
- return job
- }
-
- public func job(id: ReviewJob.ID) -> ReviewJob? {
- jobsByID[id]
- }
-
- public func apply(_ event: ReviewDomainEvent, to jobID: ReviewJob.ID, at timestamp: Date = Date()) {
- let job = upsertJob(id: jobID)
- job.timeline.apply(event, at: timestamp)
- }
-}
diff --git a/Sources/CodexReviewDomain/ReviewDomainEvent.swift b/Sources/CodexReviewDomain/ReviewDomainEvent.swift
deleted file mode 100644
index c0d89489..00000000
--- a/Sources/CodexReviewDomain/ReviewDomainEvent.swift
+++ /dev/null
@@ -1,43 +0,0 @@
-import Foundation
-
-public enum ReviewDomainEvent: Equatable, Sendable {
- case runStarted(turnID: ReviewTurn.ID, reviewThreadID: ReviewThread.ID?, model: String?)
- case itemStarted(ReviewTimelineItemSeed)
- case itemUpdated(ReviewTimelineItemSeed)
- case itemCompleted(ReviewTimelineItemSeed)
- case textDelta(itemID: ReviewTimelineItem.ID, kind: ReviewItemKind, family: ReviewItemFamily, content: ReviewTimelineItem.Content, delta: String)
- case reviewCompleted(summary: String, result: String?)
- case reviewFailed(String)
- case reviewCancelled(String)
-}
-
-public struct ReviewTimelineItemSeed: Equatable, Sendable {
- public var id: ReviewTimelineItem.ID
- public var kind: ReviewItemKind
- public var family: ReviewItemFamily
- public var phase: ReviewItemPhase
- public var content: ReviewTimelineItem.Content
- public var startedAt: Date?
- public var completedAt: Date?
- public var durationMs: Int?
-
- public init(
- id: ReviewTimelineItem.ID,
- kind: ReviewItemKind,
- family: ReviewItemFamily,
- phase: ReviewItemPhase,
- content: ReviewTimelineItem.Content,
- startedAt: Date? = nil,
- completedAt: Date? = nil,
- durationMs: Int? = nil
- ) {
- self.id = id
- self.kind = kind
- self.family = family
- self.phase = phase
- self.content = content
- self.startedAt = startedAt
- self.completedAt = completedAt
- self.durationMs = durationMs
- }
-}
diff --git a/Sources/CodexReviewDomain/ReviewIdentifiers.swift b/Sources/CodexReviewDomain/ReviewIdentifiers.swift
deleted file mode 100644
index 4fd3d867..00000000
--- a/Sources/CodexReviewDomain/ReviewIdentifiers.swift
+++ /dev/null
@@ -1,95 +0,0 @@
-import Foundation
-
-public protocol ReviewStringIdentifier: RawRepresentable, Codable, Hashable, Sendable, ExpressibleByStringLiteral where RawValue == String {
- init(rawValue: String)
-}
-
-public extension ReviewStringIdentifier {
- init(stringLiteral value: String) {
- self.init(rawValue: value)
- }
-}
-
-public struct ReviewJobID: ReviewStringIdentifier, CustomStringConvertible {
- public var rawValue: String
-
- public init(rawValue: String) {
- self.rawValue = rawValue
- }
-
- public var description: String {
- rawValue
- }
-}
-
-public struct ReviewRunID: ReviewStringIdentifier, CustomStringConvertible {
- public var rawValue: String
-
- public init(rawValue: String) {
- self.rawValue = rawValue
- }
-
- public var description: String {
- rawValue
- }
-}
-
-public struct ReviewThreadID: ReviewStringIdentifier, CustomStringConvertible {
- public var rawValue: String
-
- public init(rawValue: String) {
- self.rawValue = rawValue
- }
-
- public var description: String {
- rawValue
- }
-}
-
-public struct ReviewTurnID: ReviewStringIdentifier, CustomStringConvertible {
- public var rawValue: String
-
- public init(rawValue: String) {
- self.rawValue = rawValue
- }
-
- public var description: String {
- rawValue
- }
-}
-
-public struct ReviewTimelineItemID: ReviewStringIdentifier, CustomStringConvertible {
- public var rawValue: String
-
- public init(rawValue: String) {
- self.rawValue = rawValue
- }
-
- public var description: String {
- rawValue
- }
-}
-
-public struct ReviewToolCallID: ReviewStringIdentifier, CustomStringConvertible {
- public var rawValue: String
-
- public init(rawValue: String) {
- self.rawValue = rawValue
- }
-
- public var description: String {
- rawValue
- }
-}
-
-public enum ReviewThread {
- public typealias ID = ReviewThreadID
-}
-
-public enum ReviewTurn {
- public typealias ID = ReviewTurnID
-}
-
-public enum ReviewToolCall {
- public typealias ID = ReviewToolCallID
-}
diff --git a/Sources/CodexReviewDomain/ReviewJob.swift b/Sources/CodexReviewDomain/ReviewJob.swift
deleted file mode 100644
index bed4e229..00000000
--- a/Sources/CodexReviewDomain/ReviewJob.swift
+++ /dev/null
@@ -1,34 +0,0 @@
-import Foundation
-import Observation
-
-@MainActor
-@Observable
-public final class ReviewJob: Identifiable, Hashable {
- public typealias ID = ReviewJobID
-
- public nonisolated let id: ID
- public private(set) var run: ReviewRun?
- public let timeline: ReviewTimeline
-
- public init(
- id: ID,
- run: ReviewRun? = nil,
- timeline: ReviewTimeline = ReviewTimeline()
- ) {
- self.id = id
- self.run = run
- self.timeline = timeline
- }
-
- public func updateRun(_ run: ReviewRun?) {
- self.run = run
- }
-
- public nonisolated static func == (lhs: ReviewJob, rhs: ReviewJob) -> Bool {
- lhs.id == rhs.id
- }
-
- public nonisolated func hash(into hasher: inout Hasher) {
- hasher.combine(id)
- }
-}
diff --git a/Sources/CodexReviewDomain/ReviewKinds.swift b/Sources/CodexReviewDomain/ReviewKinds.swift
deleted file mode 100644
index f77b35b6..00000000
--- a/Sources/CodexReviewDomain/ReviewKinds.swift
+++ /dev/null
@@ -1,294 +0,0 @@
-import Foundation
-
-public struct ReviewItemKind: ReviewStringIdentifier, CustomStringConvertible {
- public var rawValue: String
-
- public init(rawValue: String) {
- self.rawValue = rawValue
- }
-
- public var description: String {
- rawValue
- }
-
- public static let agentMessage: Self = "agentMessage"
- public static let commandExecution: Self = "commandExecution"
- public static let contextCompaction: Self = "contextCompaction"
- public static let dynamicToolCall: Self = "dynamicToolCall"
- public static let fileChange: Self = "fileChange"
- public static let imageGeneration: Self = "imageGeneration"
- public static let imageView: Self = "imageView"
- public static let mcpToolCall: Self = "mcpToolCall"
- public static let plan: Self = "plan"
- public static let reasoning: Self = "reasoning"
- public static let webSearch: Self = "webSearch"
-}
-
-public struct ReviewWireEventKind: ReviewStringIdentifier, CustomStringConvertible {
- public var rawValue: String
-
- public init(rawValue: String) {
- self.rawValue = rawValue
- }
-
- public var description: String {
- rawValue
- }
-
- public static let turnStarted: Self = "turn/started"
- public static let itemStarted: Self = "item/started"
- public static let itemCompleted: Self = "item/completed"
- public static let agentMessageDelta: Self = "item/agentMessage/delta"
- public static let planDelta: Self = "item/plan/delta"
- public static let reasoningSummaryTextDelta: Self = "item/reasoning/summaryTextDelta"
- public static let reasoningTextDelta: Self = "item/reasoning/textDelta"
- public static let commandExecutionOutputDelta: Self = "item/commandExecution/outputDelta"
- public static let fileChangeOutputDelta: Self = "item/fileChange/outputDelta"
- public static let mcpToolCallProgress: Self = "item/mcpToolCall/progress"
-}
-
-public protocol ReviewOpenStringValue: ReviewStringIdentifier, CustomStringConvertible {}
-
-public extension ReviewOpenStringValue {
- var description: String {
- rawValue
- }
-}
-
-public struct ReviewCommandStatus: ReviewOpenStringValue {
- public var rawValue: String
-
- public init(rawValue: String) {
- self.rawValue = rawValue
- }
-
- public static let inProgress: Self = "inProgress"
- public static let completed: Self = "completed"
- public static let failed: Self = "failed"
- public static let cancelled: Self = "cancelled"
-}
-
-public struct ReviewCommandSource: ReviewOpenStringValue {
- public var rawValue: String
-
- public init(rawValue: String) {
- self.rawValue = rawValue
- }
-}
-
-public struct ReviewCommandActionKind: ReviewOpenStringValue {
- public var rawValue: String
-
- public init(rawValue: String) {
- self.rawValue = rawValue
- }
-
- public static let read: Self = "read"
- public static let listFiles: Self = "listFiles"
- public static let search: Self = "search"
- public static let unknown: Self = "unknown"
-}
-
-public struct ReviewToolCallStatus: ReviewOpenStringValue {
- public var rawValue: String
-
- public init(rawValue: String) {
- self.rawValue = rawValue
- }
-
- public static let started: Self = "started"
- public static let inProgress: Self = "inProgress"
- public static let completed: Self = "completed"
- public static let failed: Self = "failed"
- public static let cancelled: Self = "cancelled"
-}
-
-public struct ReviewAppContext: ReviewOpenStringValue {
- public var rawValue: String
-
- public init(rawValue: String) {
- self.rawValue = rawValue
- }
-}
-
-public struct ReviewPluginID: ReviewOpenStringValue {
- public var rawValue: String
-
- public init(rawValue: String) {
- self.rawValue = rawValue
- }
-}
-
-public struct ReviewFileChangeStatus: ReviewOpenStringValue {
- public var rawValue: String
-
- public init(rawValue: String) {
- self.rawValue = rawValue
- }
-
- public static let started: Self = "started"
- public static let updated: Self = "updated"
- public static let completed: Self = "completed"
- public static let failed: Self = "failed"
-}
-
-public struct ReviewApprovalDecision: ReviewOpenStringValue {
- public var rawValue: String
-
- public init(rawValue: String) {
- self.rawValue = rawValue
- }
-
- public static let approved: Self = "approved"
- public static let denied: Self = "denied"
- public static let cancelled: Self = "cancelled"
-}
-
-public struct ReviewApprovalScope: ReviewOpenStringValue {
- public var rawValue: String
-
- public init(rawValue: String) {
- self.rawValue = rawValue
- }
-}
-
-public struct ReviewApprovalRisk: ReviewOpenStringValue {
- public var rawValue: String
-
- public init(rawValue: String) {
- self.rawValue = rawValue
- }
-
- public static let low: Self = "low"
- public static let medium: Self = "medium"
- public static let high: Self = "high"
-}
-
-public struct ReviewApprovalStatus: ReviewOpenStringValue {
- public var rawValue: String
-
- public init(rawValue: String) {
- self.rawValue = rawValue
- }
-
- public static let pending: Self = "pending"
- public static let decided: Self = "decided"
- public static let cancelled: Self = "cancelled"
-}
-
-public struct ReviewDiagnosticSeverity: ReviewOpenStringValue {
- public var rawValue: String
-
- public init(rawValue: String) {
- self.rawValue = rawValue
- }
-
- public static let info: Self = "info"
- public static let warning: Self = "warning"
- public static let error: Self = "error"
-}
-
-public struct ReviewDiagnosticRetryState: ReviewOpenStringValue {
- public var rawValue: String
-
- public init(rawValue: String) {
- self.rawValue = rawValue
- }
-
- public static let scheduled: Self = "scheduled"
- public static let retrying: Self = "retrying"
- public static let exhausted: Self = "exhausted"
-}
-
-public struct ReviewSearchStatus: ReviewOpenStringValue {
- public var rawValue: String
-
- public init(rawValue: String) {
- self.rawValue = rawValue
- }
-
- public static let started: Self = "started"
- public static let completed: Self = "completed"
- public static let failed: Self = "failed"
-}
-
-public struct ReviewContextCompactionStatus: ReviewOpenStringValue {
- public var rawValue: String
-
- public init(rawValue: String) {
- self.rawValue = rawValue
- }
-
- public static let inProgress: Self = "inProgress"
- public static let completed: Self = "completed"
- public static let failed: Self = "failed"
-}
-
-public struct ReviewRawReferenceKind: ReviewOpenStringValue {
- public var rawValue: String
-
- public init(rawValue: String) {
- self.rawValue = rawValue
- }
-}
-
-public enum ReviewItemFamily: String, Codable, Hashable, Sendable {
- case approval
- case command
- case contextCompaction
- case diagnostic
- case fileChange
- case lifecycle
- case message
- case plan
- case reasoning
- case search
- case tool
- case unknown
-}
-
-public enum ReviewItemPhase: String, Codable, Hashable, Sendable {
- case awaitingApproval
- case cancelled
- case completed
- case failed
- case incomplete
- case queued
- case running
- case skipped
- case waitingForInput
-
- public var isTerminal: Bool {
- switch self {
- case .cancelled, .completed, .failed, .incomplete, .skipped:
- return true
- case .awaitingApproval, .queued, .running, .waitingForInput:
- return false
- }
- }
-
- public static func normalized(_ rawValue: String?) -> Self {
- switch rawValue?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
- case "approved", "completed", "succeeded", "success":
- return .completed
- case "cancelled", "canceled":
- return .cancelled
- case "failed", "failure", "error":
- return .failed
- case "incomplete":
- return .incomplete
- case "skipped":
- return .skipped
- case "approval", "awaitingapproval", "pendingapproval":
- return .awaitingApproval
- case "queued", "pending":
- return .queued
- case "waiting", "waitingforinput", "inputrequired":
- return .waitingForInput
- case "inprogress", "running", "started":
- return .running
- default:
- return .running
- }
- }
-}
diff --git a/Sources/CodexReviewDomain/ReviewRun.swift b/Sources/CodexReviewDomain/ReviewRun.swift
deleted file mode 100644
index 0ccfa809..00000000
--- a/Sources/CodexReviewDomain/ReviewRun.swift
+++ /dev/null
@@ -1,95 +0,0 @@
-import Foundation
-import Observation
-
-public enum ReviewLifecycleStatus: String, Codable, Hashable, Sendable {
- case queued
- case running
- case succeeded
- case failed
- case cancelled
- case incomplete
-
- public var isTerminal: Bool {
- switch self {
- case .succeeded, .failed, .cancelled, .incomplete:
- true
- case .queued, .running:
- false
- }
- }
-}
-
-@MainActor
-@Observable
-public final class ReviewRun: Identifiable, Hashable {
- public typealias ID = ReviewRunID
-
- public nonisolated let id: ID
- public private(set) var threadID: ReviewThread.ID?
- public private(set) var turnID: ReviewTurn.ID?
- public private(set) var reviewThreadID: ReviewThread.ID?
- public private(set) var model: String?
- public private(set) var status: ReviewLifecycleStatus
- public private(set) var startedAt: Date?
- public private(set) var endedAt: Date?
-
- public init(
- id: ID,
- threadID: ReviewThread.ID? = nil,
- turnID: ReviewTurn.ID? = nil,
- reviewThreadID: ReviewThread.ID? = nil,
- model: String? = nil,
- status: ReviewLifecycleStatus = .queued,
- startedAt: Date? = nil,
- endedAt: Date? = nil
- ) {
- self.id = id
- self.threadID = threadID
- self.turnID = turnID
- self.reviewThreadID = reviewThreadID
- self.model = model
- self.status = status
- self.startedAt = startedAt
- self.endedAt = endedAt
- }
-
- public func update(
- threadID: ReviewThread.ID? = nil,
- turnID: ReviewTurn.ID? = nil,
- reviewThreadID: ReviewThread.ID? = nil,
- model: String? = nil,
- status: ReviewLifecycleStatus? = nil,
- startedAt: Date? = nil,
- endedAt: Date? = nil
- ) {
- if let threadID {
- self.threadID = threadID
- }
- if let turnID {
- self.turnID = turnID
- }
- if let reviewThreadID {
- self.reviewThreadID = reviewThreadID
- }
- if let model {
- self.model = model
- }
- if let status {
- self.status = status
- }
- if let startedAt {
- self.startedAt = startedAt
- }
- if let endedAt {
- self.endedAt = endedAt
- }
- }
-
- public nonisolated static func == (lhs: ReviewRun, rhs: ReviewRun) -> Bool {
- lhs.id == rhs.id
- }
-
- public nonisolated func hash(into hasher: inout Hasher) {
- hasher.combine(id)
- }
-}
diff --git a/Sources/CodexReviewDomain/ReviewTimeline.swift b/Sources/CodexReviewDomain/ReviewTimeline.swift
deleted file mode 100644
index e57fb126..00000000
--- a/Sources/CodexReviewDomain/ReviewTimeline.swift
+++ /dev/null
@@ -1,211 +0,0 @@
-import Foundation
-import Observation
-
-@MainActor
-@Observable
-public final class ReviewTimeline {
- public struct Revision: RawRepresentable, Codable, Hashable, Sendable, Comparable {
- public var rawValue: UInt64
-
- public init(rawValue: UInt64) {
- self.rawValue = rawValue
- }
-
- public static let initial = Revision(rawValue: 0)
-
- public static func < (lhs: Revision, rhs: Revision) -> Bool {
- lhs.rawValue < rhs.rawValue
- }
- }
-
- public private(set) var revision: Revision = .initial
- public private(set) var orderedItemIDs: [ReviewTimelineItem.ID] = []
- public private(set) var itemsByID: [ReviewTimelineItem.ID: ReviewTimelineItem] = [:]
- public private(set) var activeItemIDs: Set = []
- public private(set) var latestActivity: ReviewTimelineItem.ID?
- public private(set) var terminalStatus: ReviewLifecycleStatus?
- public private(set) var terminalSummary: String?
- public private(set) var terminalResult: String?
-
- public init() {}
-
- public var items: [ReviewTimelineItem] {
- orderedItemIDs.compactMap { itemsByID[$0] }
- }
-
- public var isTerminal: Bool {
- terminalStatus != nil
- }
-
- public func reset(keepingTerminal: Bool = true) {
- orderedItemIDs.removeAll(keepingCapacity: true)
- itemsByID.removeAll(keepingCapacity: true)
- activeItemIDs.removeAll(keepingCapacity: true)
- latestActivity = nil
- if keepingTerminal == false {
- terminalStatus = nil
- terminalSummary = nil
- terminalResult = nil
- }
- bumpRevision()
- }
-
- public func apply(_ event: ReviewDomainEvent, at timestamp: Date = Date()) {
- switch event {
- case .runStarted:
- terminalStatus = nil
- terminalSummary = nil
- terminalResult = nil
- bumpRevision()
- case .itemStarted(let seed):
- applySeed(seed, timestamp: timestamp, activity: .phaseDriven)
- case .itemUpdated(let seed):
- applySeed(seed, timestamp: timestamp, activity: .phaseDriven)
- case .itemCompleted(let seed):
- applySeed(seed, timestamp: timestamp, activity: .inactive)
- case .textDelta(let itemID, let kind, let family, let content, let delta):
- let item = item(for: itemID) ?? insert(
- id: itemID,
- kind: kind,
- family: family,
- phase: .running,
- content: content,
- timestamp: timestamp
- )
- item.appendText(delta, updatedAt: timestamp)
- synchronizeActiveMembership(for: item, activity: .phaseDriven)
- latestActivity = item.id
- bumpRevision()
- case .reviewCompleted(let summary, let result):
- terminalStatus = .succeeded
- terminalSummary = summary
- terminalResult = result
- activeItemIDs.removeAll(keepingCapacity: true)
- bumpRevision()
- case .reviewFailed(let message):
- terminalStatus = .failed
- terminalSummary = message
- terminalResult = nil
- activeItemIDs.removeAll(keepingCapacity: true)
- bumpRevision()
- case .reviewCancelled(let message):
- terminalStatus = .cancelled
- terminalSummary = message
- terminalResult = nil
- activeItemIDs.removeAll(keepingCapacity: true)
- bumpRevision()
- }
- }
-
- public func item(for id: ReviewTimelineItem.ID) -> ReviewTimelineItem? {
- itemsByID[id]
- }
-
- @discardableResult
- package func updateItemContent(
- _ content: ReviewTimelineItem.Content,
- for id: ReviewTimelineItem.ID,
- updatedAt: Date? = nil
- ) -> Bool {
- guard let item = itemsByID[id],
- item.content != content
- else {
- return false
- }
- item.update(content: content, updatedAt: updatedAt ?? item.updatedAt)
- bumpRevision()
- return true
- }
-
- @discardableResult
- private func applySeed(
- _ seed: ReviewTimelineItemSeed,
- timestamp: Date,
- activity: ItemActivity
- ) -> ReviewTimelineItem {
- let item = upsert(seed, timestamp: timestamp)
- item.update(
- kind: seed.kind,
- family: seed.family,
- phase: seed.phase,
- content: item.content.mergingTimelineUpdate(seed.content),
- updatedAt: timestamp,
- startedAt: seed.startedAt,
- completedAt: seed.completedAt,
- durationMs: seed.durationMs
- )
- synchronizeActiveMembership(for: item, activity: activity)
- latestActivity = item.id
- bumpRevision()
- return item
- }
-
- private enum ItemActivity {
- case inactive
- case phaseDriven
- }
-
- private func synchronizeActiveMembership(for item: ReviewTimelineItem, activity: ItemActivity) {
- switch activity {
- case .inactive:
- activeItemIDs.remove(item.id)
- case .phaseDriven:
- if isTerminal || item.phase.isTerminal {
- activeItemIDs.remove(item.id)
- } else {
- activeItemIDs.insert(item.id)
- }
- }
- }
-
- @discardableResult
- private func upsert(_ seed: ReviewTimelineItemSeed, timestamp: Date) -> ReviewTimelineItem {
- if let item = itemsByID[seed.id] {
- return item
- }
- return insert(
- id: seed.id,
- kind: seed.kind,
- family: seed.family,
- phase: seed.phase,
- content: seed.content,
- timestamp: timestamp,
- startedAt: seed.startedAt,
- completedAt: seed.completedAt,
- durationMs: seed.durationMs
- )
- }
-
- @discardableResult
- private func insert(
- id: ReviewTimelineItem.ID,
- kind: ReviewItemKind,
- family: ReviewItemFamily,
- phase: ReviewItemPhase,
- content: ReviewTimelineItem.Content,
- timestamp: Date,
- startedAt: Date? = nil,
- completedAt: Date? = nil,
- durationMs: Int? = nil
- ) -> ReviewTimelineItem {
- let item = ReviewTimelineItem(
- id: id,
- kind: kind,
- family: family,
- phase: phase,
- content: content,
- createdAt: timestamp,
- updatedAt: timestamp,
- startedAt: startedAt,
- completedAt: completedAt,
- durationMs: durationMs
- )
- itemsByID[id] = item
- orderedItemIDs.append(id)
- return item
- }
-
- private func bumpRevision() {
- revision = Revision(rawValue: revision.rawValue &+ 1)
- }
-}
diff --git a/Sources/CodexReviewDomain/ReviewTimelineItem.swift b/Sources/CodexReviewDomain/ReviewTimelineItem.swift
deleted file mode 100644
index 7ad2907e..00000000
--- a/Sources/CodexReviewDomain/ReviewTimelineItem.swift
+++ /dev/null
@@ -1,586 +0,0 @@
-import Foundation
-import Observation
-
-@MainActor
-@Observable
-public final class ReviewTimelineItem: Identifiable, Hashable {
- public typealias ID = ReviewTimelineItemID
-
- public enum Content: Equatable, Codable, Sendable {
- case approval(Approval)
- case command(Command)
- case contextCompaction(ContextCompaction)
- case diagnostic(Diagnostic)
- case fileChange(FileChange)
- case message(Message)
- case plan(Plan)
- case reasoning(Reasoning)
- case search(Search)
- case toolCall(ToolCall)
- case unknown(Unknown)
- }
-
- public struct RawReference: Equatable, Codable, Sendable {
- public var kind: ReviewRawReferenceKind
- public var value: String
- public var label: String?
-
- public init(kind: ReviewRawReferenceKind, value: String, label: String? = nil) {
- self.kind = kind
- self.value = value
- self.label = label
- }
- }
-
- public struct Approval: Equatable, Codable, Sendable {
- public var title: String
- public var detail: String?
- public var decision: ReviewApprovalDecision?
- public var scope: ReviewApprovalScope?
- public var risk: ReviewApprovalRisk?
- public var status: ReviewApprovalStatus?
-
- public init(
- title: String,
- detail: String? = nil,
- decision: ReviewApprovalDecision? = nil,
- scope: ReviewApprovalScope? = nil,
- risk: ReviewApprovalRisk? = nil,
- status: ReviewApprovalStatus? = nil
- ) {
- self.title = title
- self.detail = detail
- self.decision = decision
- self.scope = scope
- self.risk = risk
- self.status = status
- }
- }
-
- public struct CommandAction: Equatable, Codable, Sendable {
- public var kind: ReviewCommandActionKind
- public var command: String?
- public var name: String?
- public var path: String?
- public var query: String?
-
- public init(
- kind: ReviewCommandActionKind,
- 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 struct Command: Equatable, Codable, Sendable {
- public var command: String
- public var cwd: String?
- public var output: String
- public var exitCode: Int?
- public var status: ReviewCommandStatus?
- public var source: ReviewCommandSource?
- public var processID: String?
- public var actions: [CommandAction]
- public var durationMs: Int?
-
- public init(
- command: String,
- cwd: String? = nil,
- output: String = "",
- exitCode: Int? = nil,
- status: ReviewCommandStatus? = nil,
- source: ReviewCommandSource? = nil,
- processID: String? = nil,
- actions: [CommandAction] = [],
- durationMs: Int? = nil
- ) {
- self.command = command
- self.cwd = cwd
- self.output = output
- self.exitCode = exitCode
- self.status = status
- self.source = source
- self.processID = processID
- self.actions = actions
- self.durationMs = durationMs
- }
- }
-
- public struct ContextCompaction: Equatable, Codable, Sendable {
- public var title: String
- public var status: ReviewContextCompactionStatus?
- public var inputTokens: Int?
- public var outputTokens: Int?
-
- public init(
- title: String,
- status: ReviewContextCompactionStatus? = nil,
- inputTokens: Int? = nil,
- outputTokens: Int? = nil
- ) {
- self.title = title
- self.status = status
- self.inputTokens = inputTokens
- self.outputTokens = outputTokens
- }
- }
-
- public struct Retry: Equatable, Codable, Sendable {
- public var state: ReviewDiagnosticRetryState
- public var attempt: Int?
- public var maxAttempts: Int?
- public var delayMs: Int?
-
- public init(
- state: ReviewDiagnosticRetryState,
- attempt: Int? = nil,
- maxAttempts: Int? = nil,
- delayMs: Int? = nil
- ) {
- self.state = state
- self.attempt = attempt
- self.maxAttempts = maxAttempts
- self.delayMs = delayMs
- }
- }
-
- public struct Diagnostic: Equatable, Codable, Sendable {
- public var message: String
- public var severity: ReviewDiagnosticSeverity?
- public var retry: Retry?
-
- public init(
- message: String,
- severity: ReviewDiagnosticSeverity? = nil,
- retry: Retry? = nil
- ) {
- self.message = message
- self.severity = severity
- self.retry = retry
- }
- }
-
- public struct FileChange: Equatable, Codable, Sendable {
- public var title: String
- public var output: String
- public var paths: [String]
- public var patch: String?
- public var status: ReviewFileChangeStatus?
-
- public init(
- title: String,
- output: String = "",
- paths: [String] = [],
- patch: String? = nil,
- status: ReviewFileChangeStatus? = nil
- ) {
- self.title = title
- self.output = output
- self.paths = paths
- self.patch = patch
- self.status = status
- }
- }
-
- public struct Message: Equatable, Codable, Sendable {
- public var text: String
-
- public init(text: String) {
- self.text = text
- }
- }
-
- public struct Plan: Equatable, Codable, Sendable {
- public var markdown: String
-
- public init(markdown: String) {
- self.markdown = markdown
- }
- }
-
- public struct Reasoning: Equatable, Codable, Sendable {
- public enum Style: String, Codable, Hashable, Sendable {
- case raw
- case summary
- }
-
- public var text: String
- public var style: Style
-
- public init(text: String, style: Style) {
- self.text = text
- self.style = style
- }
- }
-
- public struct Search: Equatable, Codable, Sendable {
- public var query: String
- public var result: String?
- public var status: ReviewSearchStatus?
- public var resultCount: Int?
- public var durationMs: Int?
-
- public init(
- query: String,
- result: String? = nil,
- status: ReviewSearchStatus? = nil,
- resultCount: Int? = nil,
- durationMs: Int? = nil
- ) {
- self.query = query
- self.result = result
- self.status = status
- self.resultCount = resultCount
- self.durationMs = durationMs
- }
- }
-
- public struct ToolCall: Equatable, Codable, Sendable {
- public var namespace: String?
- public var server: String?
- public var tool: String?
- public var arguments: String?
- public var result: String?
- public var error: String?
- public var status: ReviewToolCallStatus?
- public var durationMs: Int?
- public var appContext: ReviewAppContext?
- public var pluginID: ReviewPluginID?
- public var callID: ReviewToolCall.ID?
- public var progress: String?
-
- public init(
- namespace: String? = nil,
- server: String? = nil,
- tool: String? = nil,
- arguments: String? = nil,
- result: String? = nil,
- error: String? = nil,
- status: ReviewToolCallStatus? = nil,
- durationMs: Int? = nil,
- appContext: ReviewAppContext? = nil,
- pluginID: ReviewPluginID? = nil,
- callID: ReviewToolCall.ID? = nil,
- progress: String? = nil
- ) {
- self.namespace = namespace
- self.server = server
- self.tool = tool
- self.arguments = arguments
- self.result = result
- self.error = error
- self.status = status
- self.durationMs = durationMs
- self.appContext = appContext
- self.pluginID = pluginID
- self.callID = callID
- self.progress = progress
- }
- }
-
- public struct Unknown: Equatable, Codable, Sendable {
- public var title: String
- public var detail: String?
- public var rawKind: ReviewItemKind?
- public var rawStatus: String?
- public var references: [RawReference]
-
- public init(
- title: String,
- detail: String? = nil,
- rawKind: ReviewItemKind? = nil,
- rawStatus: String? = nil,
- references: [RawReference] = []
- ) {
- self.title = title
- self.detail = detail
- self.rawKind = rawKind
- self.rawStatus = rawStatus
- self.references = references
- }
- }
-
- public nonisolated let id: ID
- public private(set) var kind: ReviewItemKind
- public private(set) var family: ReviewItemFamily
- public private(set) var phase: ReviewItemPhase
- public private(set) var content: Content
- public private(set) var createdAt: Date
- public private(set) var updatedAt: Date
- public private(set) var startedAt: Date?
- public private(set) var completedAt: Date?
- public private(set) var durationMs: Int?
-
- public init(
- id: ID,
- kind: ReviewItemKind,
- family: ReviewItemFamily,
- phase: ReviewItemPhase,
- content: Content,
- createdAt: Date = Date(),
- updatedAt: Date? = nil,
- startedAt: Date? = nil,
- completedAt: Date? = nil,
- durationMs: Int? = nil
- ) {
- self.id = id
- self.kind = kind
- self.family = family
- self.phase = phase
- self.content = content
- self.createdAt = createdAt
- self.updatedAt = updatedAt ?? createdAt
- self.startedAt = startedAt
- self.completedAt = completedAt
- self.durationMs = durationMs
- }
-
- public nonisolated static func == (lhs: ReviewTimelineItem, rhs: ReviewTimelineItem) -> Bool {
- lhs.id == rhs.id
- }
-
- public nonisolated func hash(into hasher: inout Hasher) {
- hasher.combine(id)
- }
-
- public func update(
- kind: ReviewItemKind? = nil,
- family: ReviewItemFamily? = nil,
- phase: ReviewItemPhase? = nil,
- content: Content? = nil,
- updatedAt: Date = Date(),
- startedAt: Date? = nil,
- completedAt: Date? = nil,
- durationMs: Int? = nil
- ) {
- if let kind {
- self.kind = kind
- }
- if let family {
- self.family = family
- }
- if let phase {
- self.phase = phase
- }
- if let content {
- self.content = content
- }
- if let startedAt {
- self.startedAt = startedAt
- }
- if let completedAt {
- self.completedAt = completedAt
- }
- if let durationMs {
- self.durationMs = durationMs
- }
- self.updatedAt = updatedAt
- }
-
- public func appendText(_ delta: String, updatedAt: Date = Date()) {
- guard delta.isEmpty == false else {
- return
- }
- switch content {
- case .command(var command):
- command.output += delta
- content = .command(command)
- case .fileChange(var fileChange):
- fileChange.output += delta
- content = .fileChange(fileChange)
- case .message(var message):
- message.text += delta
- content = .message(message)
- case .plan(var plan):
- plan.markdown += delta
- content = .plan(plan)
- case .reasoning(var reasoning):
- reasoning.text += delta
- content = .reasoning(reasoning)
- case .diagnostic(var diagnostic):
- diagnostic.message += delta
- content = .diagnostic(diagnostic)
- case .toolCall(var toolCall):
- toolCall.progress = (toolCall.progress ?? "") + delta
- content = .toolCall(toolCall)
- case .approval, .contextCompaction, .search, .unknown:
- break
- }
- self.updatedAt = updatedAt
- }
-}
-
-extension ReviewTimelineItem.Content {
- func mergingTimelineUpdate(_ incoming: Self) -> Self {
- switch (self, incoming) {
- case (.approval(let current), .approval(let update)):
- return .approval(current.merging(update))
- case (.command(let current), .command(let update)):
- return .command(current.merging(update))
- case (.contextCompaction(let current), .contextCompaction(let update)):
- return .contextCompaction(current.merging(update))
- case (.diagnostic(let current), .diagnostic(let update)):
- return .diagnostic(current.merging(update))
- case (.fileChange(let current), .fileChange(let update)):
- return .fileChange(current.merging(update))
- case (.message(let current), .message(let update)):
- return .message(current.merging(update))
- case (.plan(let current), .plan(let update)):
- return .plan(current.merging(update))
- case (.reasoning(let current), .reasoning(let update)):
- return .reasoning(current.merging(update))
- case (.search(let current), .search(let update)):
- return .search(current.merging(update))
- case (.toolCall(let current), .toolCall(let update)):
- return .toolCall(current.merging(update))
- case (.unknown(let current), .unknown(let update)):
- return .unknown(current.merging(update))
- default:
- return incoming
- }
- }
-}
-
-private extension ReviewTimelineItem.Approval {
- func merging(_ incoming: Self) -> Self {
- .init(
- title: merged(current: title, incoming: incoming.title),
- detail: merged(current: detail, incoming: incoming.detail),
- decision: incoming.decision ?? decision,
- scope: incoming.scope ?? scope,
- risk: incoming.risk ?? risk,
- status: incoming.status ?? status
- )
- }
-}
-
-private extension ReviewTimelineItem.Command {
- func merging(_ incoming: Self) -> Self {
- .init(
- command: merged(current: command, incoming: incoming.command),
- cwd: merged(current: cwd, incoming: incoming.cwd),
- output: merged(current: output, incoming: incoming.output),
- exitCode: incoming.exitCode ?? exitCode,
- status: incoming.status ?? status,
- source: incoming.source ?? source,
- processID: merged(current: processID, incoming: incoming.processID),
- actions: incoming.actions.isEmpty ? actions : incoming.actions,
- durationMs: incoming.durationMs ?? durationMs
- )
- }
-}
-
-private extension ReviewTimelineItem.ContextCompaction {
- func merging(_ incoming: Self) -> Self {
- .init(
- title: merged(current: title, incoming: incoming.title),
- status: incoming.status ?? status,
- inputTokens: incoming.inputTokens ?? inputTokens,
- outputTokens: incoming.outputTokens ?? outputTokens
- )
- }
-}
-
-private extension ReviewTimelineItem.Diagnostic {
- func merging(_ incoming: Self) -> Self {
- .init(
- message: merged(current: message, incoming: incoming.message),
- severity: incoming.severity ?? severity,
- retry: incoming.retry ?? retry
- )
- }
-}
-
-private extension ReviewTimelineItem.FileChange {
- func merging(_ incoming: Self) -> Self {
- .init(
- title: merged(current: title, incoming: incoming.title),
- output: merged(current: output, incoming: incoming.output),
- paths: incoming.paths.isEmpty ? paths : incoming.paths,
- patch: merged(current: patch, incoming: incoming.patch),
- status: incoming.status ?? status
- )
- }
-}
-
-private extension ReviewTimelineItem.Message {
- func merging(_ incoming: Self) -> Self {
- .init(text: merged(current: text, incoming: incoming.text))
- }
-}
-
-private extension ReviewTimelineItem.Plan {
- func merging(_ incoming: Self) -> Self {
- .init(markdown: merged(current: markdown, incoming: incoming.markdown))
- }
-}
-
-private extension ReviewTimelineItem.Reasoning {
- func merging(_ incoming: Self) -> Self {
- if incoming.text.isEmpty {
- return self
- }
- return incoming
- }
-}
-
-private extension ReviewTimelineItem.Search {
- func merging(_ incoming: Self) -> Self {
- .init(
- query: merged(current: query, incoming: incoming.query),
- result: merged(current: result, incoming: incoming.result),
- status: incoming.status ?? status,
- resultCount: incoming.resultCount ?? resultCount,
- durationMs: incoming.durationMs ?? durationMs
- )
- }
-}
-
-private extension ReviewTimelineItem.ToolCall {
- func merging(_ incoming: Self) -> Self {
- .init(
- namespace: merged(current: namespace, incoming: incoming.namespace),
- server: merged(current: server, incoming: incoming.server),
- tool: merged(current: tool, incoming: incoming.tool),
- arguments: merged(current: arguments, incoming: incoming.arguments),
- result: merged(current: result, incoming: incoming.result),
- error: merged(current: error, incoming: incoming.error),
- status: incoming.status ?? status,
- durationMs: incoming.durationMs ?? durationMs,
- appContext: incoming.appContext ?? appContext,
- pluginID: incoming.pluginID ?? pluginID,
- callID: incoming.callID ?? callID,
- progress: merged(current: progress, incoming: incoming.progress)
- )
- }
-}
-
-private extension ReviewTimelineItem.Unknown {
- func merging(_ incoming: Self) -> Self {
- .init(
- title: merged(current: title, incoming: incoming.title),
- detail: merged(current: detail, incoming: incoming.detail),
- rawKind: incoming.rawKind ?? rawKind,
- rawStatus: merged(current: rawStatus, incoming: incoming.rawStatus),
- references: incoming.references.isEmpty ? references : incoming.references
- )
- }
-}
-
-private func merged(current: String, incoming: String) -> String {
- incoming.isEmpty ? current : incoming
-}
-
-private func merged(current: String?, incoming: String?) -> String? {
- guard let incoming, incoming.isEmpty == false else {
- return current
- }
- return incoming
-}
diff --git a/Sources/CodexReviewHost/CodexReviewHost.swift b/Sources/CodexReviewHost/CodexReviewHost.swift
index 0b2ab211..8f9a7480 100644
--- a/Sources/CodexReviewHost/CodexReviewHost.swift
+++ b/Sources/CodexReviewHost/CodexReviewHost.swift
@@ -1,6 +1,5 @@
import Foundation
-import CodexReview
-import CodexReviewAppServer
+import CodexReviewKit
import CodexReviewMCPServer
@MainActor
@@ -28,21 +27,6 @@ package final class CodexReviewHost {
self.mcpServer = CodexReviewMCPServer(store: store)
}
- package convenience init(
- appServerTransport: any JSONRPC.Transport,
- endpoint: URL? = nil
- ) {
- let client = AppServerClient(transport: appServerTransport)
- let backend = AppServerCodexReviewBackend(client: client)
- self.init(
- backend: backend,
- endpoint: endpoint,
- shutdown: {
- await client.close()
- }
- )
- }
-
package func start(endpoint: URL? = nil) async {
if let endpoint {
self.endpoint = endpoint
@@ -244,18 +228,15 @@ private final class DirectCodexReviewStoreBackend: CodexReviewStoreBackend {
try await backend.interruptReview(run, reason: reason)
}
- func beginReviewRecovery(
- _ run: CodexReviewBackendModel.Review.Run,
- reason: CodexReviewBackendModel.CancellationReason
- ) async throws -> CodexReviewBackendModel.Review.RecoveryToken {
- try await backend.beginReviewRecovery(run, reason: reason)
+ func prepareReviewRestart(_ run: CodexReviewBackendModel.Review.Run) async throws -> CodexReviewBackendModel.Review.RestartToken {
+ try await backend.prepareReviewRestart(run)
}
- func resumeReviewRecovery(
- _ token: CodexReviewBackendModel.Review.RecoveryToken,
+ func restartPreparedReview(
+ _ token: CodexReviewBackendModel.Review.RestartToken,
request: CodexReviewBackendModel.Review.Start
) async throws -> BackendReviewAttempt {
- try await backend.resumeReviewRecovery(token, request: request)
+ try await backend.restartPreparedReview(token, request: request)
}
func cleanupReview(_ run: CodexReviewBackendModel.Review.Run) async {
@@ -278,13 +259,13 @@ private final class DirectCodexReviewStoreBackend: CodexReviewStoreBackend {
_ snapshot: CodexReviewBackendModel.Auth.Snapshot,
to auth: CodexReviewAuthModel
) {
- let accounts = snapshot.accounts.compactMap { account -> CodexAccount? in
+ let accounts = snapshot.accounts.compactMap { account -> CodexReviewAccount? in
let label = account.label.trimmingCharacters(in: .whitespacesAndNewlines)
- let accountKey = CodexAccount.normalizedEmail(account.id.rawValue)
+ let accountKey = CodexReviewAccount.normalizedEmail(account.id.rawValue)
guard label.isEmpty == false, accountKey.isEmpty == false else {
return nil
}
- return CodexAccount(
+ return CodexReviewAccount(
accountKey: accountKey,
email: label,
planType: account.planType,
@@ -293,7 +274,7 @@ private final class DirectCodexReviewStoreBackend: CodexReviewStoreBackend {
)
}
let activeAccountKey = snapshot.activeAccountID
- .map { CodexAccount.normalizedEmail($0.rawValue) }
+ .map { CodexReviewAccount.normalizedEmail($0.rawValue) }
auth.applyPersistedAccountStates(
accounts.map(savedAccountPayload(from:)),
activeAccountKey: activeAccountKey
diff --git a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift
index 29567bc9..5b8142e8 100644
--- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift
+++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift
@@ -1,12 +1,15 @@
import AppKit
import Foundation
import OSLog
-import CodexReview
+import CodexKit
+import CodexAppServerKit
+import CodexReviewKit
import CodexReviewAppServer
import CodexReviewMCPServer
private let logger = Logger(subsystem: "CodexReviewKit", category: "live-store-backend")
private typealias ExternalURLOpener = @MainActor @Sendable (URL) -> Void
+public typealias CodexReviewAppServerLifecycleHandler = @MainActor @Sendable (CodexModelContainer?) -> Void
private let defaultExternalURLOpener: ExternalURLOpener = { url in
_ = NSWorkspace.shared.open(url)
@@ -66,12 +69,12 @@ private func runRuntimeShutdownCleanup(
}
private struct PendingLoginRuntimeCleanup {
- var client: AppServerClient?
+ var appServer: CodexAppServer?
var codexHomeURL: URL?
var authenticationSession: (any CodexReviewNativeAuthentication.WebSession)?
var isEmpty: Bool {
- client == nil && codexHomeURL == nil && authenticationSession == nil
+ appServer == nil && codexHomeURL == nil && authenticationSession == nil
}
}
@@ -107,12 +110,14 @@ public extension CodexReviewStore {
static func makeLiveStore(
runtimePreferences: CodexReviewRuntime.Preferences = .defaults,
nativeAuthenticationConfiguration: CodexReviewNativeAuthentication.Configuration? = nil,
- webAuthenticationSessionFactory: @escaping CodexReviewNativeAuthentication.WebSessionFactory = CodexReviewNativeAuthentication.WebSessions.system
+ webAuthenticationSessionFactory: @escaping CodexReviewNativeAuthentication.WebSessionFactory = CodexReviewNativeAuthentication.WebSessions.system,
+ appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? = nil
) -> CodexReviewStore {
CodexReviewStore(backend: LiveCodexReviewStoreBackend(
runtimePreferences: runtimePreferences,
nativeAuthenticationConfiguration: nativeAuthenticationConfiguration,
- webAuthenticationSessionFactory: webAuthenticationSessionFactory
+ webAuthenticationSessionFactory: webAuthenticationSessionFactory,
+ appServerLifecycleHandler: appServerLifecycleHandler
))
}
@@ -127,7 +132,8 @@ public extension CodexReviewStore {
shutdownCleanupTimeout: Duration = .seconds(2),
networkMonitor: any CodexReviewNetworkMonitoring = SystemCodexReviewNetworkMonitor(),
networkRecoveryPolicy: CodexReviewNetworkRecoveryPolicy = .default,
- transport: any JSONRPC.Transport
+ appServer: CodexAppServer,
+ appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? = nil
) -> CodexReviewStore {
makeLiveStoreForTesting(
environment: environment,
@@ -140,7 +146,8 @@ public extension CodexReviewStore {
shutdownCleanupTimeout: shutdownCleanupTimeout,
networkMonitor: networkMonitor,
networkRecoveryPolicy: networkRecoveryPolicy,
- transportFactory: { _ in transport }
+ appServerLifecycleHandler: appServerLifecycleHandler,
+ appServerFactory: { _ in appServer }
)
}
@@ -152,14 +159,16 @@ public extension CodexReviewStore {
externalURLOpener: @escaping @MainActor @Sendable (URL) -> Void = defaultExternalURLOpener,
mcpHTTPServerFactory: (@MainActor @Sendable (
CodexReviewStore,
- CodexReviewMCPHTTPServer.Configuration
+ CodexReviewMCPHTTPServer.Configuration,
+ ReviewMCPLogProjectionProvider?
) -> any CodexReviewMCPHTTPServing)? = nil,
mcpPortOwnerResolver: CodexReviewMCPPortOwnerResolver? = nil,
mcpHTTPServerBindChecker: CodexReviewMCPHTTPServerBindChecker? = nil,
shutdownCleanupTimeout: Duration = .seconds(2),
networkMonitor: any CodexReviewNetworkMonitoring = SystemCodexReviewNetworkMonitor(),
networkRecoveryPolicy: CodexReviewNetworkRecoveryPolicy = .default,
- transportFactory: @escaping @MainActor @Sendable (URL) async throws -> any JSONRPC.Transport
+ appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? = nil,
+ appServerFactory: @escaping @MainActor @Sendable (URL) async throws -> CodexAppServer
) -> CodexReviewStore {
CodexReviewStore(
backend: LiveCodexReviewStoreBackend(
@@ -172,11 +181,17 @@ public extension CodexReviewStore {
mcpPortOwnerResolver: mcpPortOwnerResolver,
mcpHTTPServerBindChecker: mcpHTTPServerBindChecker,
shutdownCleanupTimeout: shutdownCleanupTimeout,
+ appServerLifecycleHandler: appServerLifecycleHandler,
appServerRuntimeFactory: { codexHomeURL in
- let client = AppServerClient(transport: try await transportFactory(codexHomeURL))
+ let appServer = try await appServerFactory(codexHomeURL)
+ let modelContainer = CodexModelContainer(appServer: appServer)
return .init(
- client: client,
- backend: AppServerCodexReviewBackend(client: client)
+ appServer: appServer,
+ modelContainer: modelContainer,
+ backend: AppServerCodexReviewBackend(
+ appServer: appServer,
+ modelContainer: modelContainer
+ )
)
}
),
@@ -190,17 +205,19 @@ public extension CodexReviewStore {
private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
typealias MCPHTTPServerFactory = @MainActor @Sendable (
CodexReviewStore,
- CodexReviewMCPHTTPServer.Configuration
+ CodexReviewMCPHTTPServer.Configuration,
+ ReviewMCPLogProjectionProvider?
) -> any CodexReviewMCPHTTPServing
let seed: CodexReviewStoreSeed
- private var client: AppServerClient?
+ private var appServer: CodexAppServer?
+ private var appServerModelContainer: CodexModelContainer?
private var appServerBackend: AppServerCodexReviewBackend?
private var mcpHTTPServer: (any CodexReviewMCPHTTPServing)?
private var loginChallenge: CodexReviewBackendModel.Login.Challenge?
private var loginBackend: AppServerCodexReviewBackend?
- private var loginClient: AppServerClient?
+ private var loginAppServer: CodexAppServer?
private var loginCodexHomeURL: URL?
private var loginActivation: LoginActivation = .activateAuthenticatedAccount
private var isWaitingForLoginAccountUpdate = false
@@ -219,6 +236,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
private let mcpHTTPServerBindChecker: CodexReviewMCPHTTPServerBindChecker
private let appServerRuntimeFactory: AppServerRuntimeFactory
private let shutdownCleanupTimeout: Duration
+ private let appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler?
private weak var attachedStore: CodexReviewStore?
init(
@@ -227,15 +245,19 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
nativeAuthenticationConfiguration: CodexReviewNativeAuthentication.Configuration? = nil,
webAuthenticationSessionFactory: @escaping CodexReviewNativeAuthentication.WebSessionFactory = CodexReviewNativeAuthentication.WebSessions.system,
externalURLOpener: @escaping ExternalURLOpener = defaultExternalURLOpener,
- mcpHTTPServerFactory: MCPHTTPServerFactory? = { store, configuration in
+ mcpHTTPServerFactory: MCPHTTPServerFactory? = { store, configuration, logProjectionProvider in
CodexReviewMCPHTTPServer(
- adapter: CodexReviewMCPServer(store: store),
+ adapter: CodexReviewMCPServer(
+ store: store,
+ logProjectionProvider: logProjectionProvider
+ ),
configuration: configuration
)
},
mcpPortOwnerResolver: CodexReviewMCPPortOwnerResolver? = nil,
mcpHTTPServerBindChecker: CodexReviewMCPHTTPServerBindChecker? = nil,
shutdownCleanupTimeout: Duration = .seconds(2),
+ appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? = nil,
appServerRuntimeFactory: AppServerRuntimeFactory? = nil
) {
let runtimePreferences = runtimePreferences.normalized
@@ -255,6 +277,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
self.mcpPortOwnerResolver = mcpPortOwnerResolver ?? Self.defaultMCPPortOwnerResolver
self.mcpHTTPServerBindChecker = mcpHTTPServerBindChecker ?? Self.defaultMCPHTTPServerBindChecker
self.shutdownCleanupTimeout = shutdownCleanupTimeout
+ self.appServerLifecycleHandler = appServerLifecycleHandler
self.appServerRuntimeFactory = appServerRuntimeFactory ?? Self.makeAppServerRuntimeFactory(
codexExecutablePath: runtimePreferences.codexExecutablePath
)
@@ -269,10 +292,10 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
}
var isActive: Bool {
- client != nil
+ appServer != nil
}
- var handlesActiveReviewStopCleanup: Bool {
+ var invokesRuntimeStopReviewCleanupDuringStop: Bool {
true
}
@@ -287,7 +310,32 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
if let codexHomePath = runtimePreferences.codexHomePath {
return URL(fileURLWithPath: codexHomePath, isDirectory: true)
}
- return AppServerCodexHome.url(environment: environment)
+ return defaultCodexReviewHomeURL(environment: environment)
+ }
+
+ private static func defaultCodexReviewHomeURL(
+ environment: [String: String],
+ 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 let home = environment["HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines),
+ home.isEmpty == false {
+ return URL(fileURLWithPath: home, isDirectory: true)
+ .appendingPathComponent(".codex_review", isDirectory: true)
+ }
+ if let applicationSupportDirectory {
+ return applicationSupportDirectory
+ .appendingPathComponent("CodexReviewMonitor", isDirectory: true)
+ }
+ return homeDirectoryForCurrentUser
+ .appendingPathComponent(".codex_review", isDirectory: true)
}
private static func defaultMCPPortOwnerResolver(
@@ -368,24 +416,22 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
codexExecutablePath: String?
) -> AppServerRuntimeFactory {
{ codexHomeURL in
- let processRuntime = try await Task.detached(priority: .userInitiated) {
+ let appServer = try await Task.detached(priority: .userInitiated) {
// The configuration probe can wait on `codex app-server --help`; keep it off the MainActor.
- let configuration = AppServerProcessTransport.Configuration(
- executable: codexExecutablePath,
- codexHomeURL: codexHomeURL
- )
- let transport = try AppServerProcessTransport(configuration: configuration)
- return AppServerProcessRuntime(
- transport: transport,
- threadStartPermissionStrategy: configuration.threadStartPermissionStrategy
- )
+ try await CodexAppServer(configuration: .init(
+ localProcess: .init(
+ executable: codexExecutablePath,
+ codexHomeURL: codexHomeURL
+ )
+ ))
}.value
- let client = AppServerClient(transport: processRuntime.transport)
+ let modelContainer = CodexModelContainer(appServer: appServer)
return .init(
- client: client,
+ appServer: appServer,
+ modelContainer: modelContainer,
backend: AppServerCodexReviewBackend(
- client: client,
- threadStartPermissionStrategy: processRuntime.threadStartPermissionStrategy
+ appServer: appServer,
+ modelContainer: modelContainer
)
)
}
@@ -406,21 +452,31 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
await stop(store: store)
}
- var startedClient: AppServerClient?
+ var startedAppServer: CodexAppServer?
var startedHTTPServer: (any CodexReviewMCPHTTPServing)?
do {
if mcpHTTPServerFactory != nil {
try await mcpHTTPServerBindChecker(mcpHTTPServerConfiguration)
}
let runtime = try await appServerRuntimeFactory(codexHomeURL)
- let client = runtime.client
+ let appServer = runtime.appServer
let backend = runtime.backend
- startedClient = client
- self.client = client
+ let modelContainer = runtime.modelContainer
+ startedAppServer = appServer
+ self.appServer = appServer
+ self.appServerModelContainer = modelContainer
self.appServerBackend = backend
- observeAuthNotifications(client: client, backend: backend, store: store)
+ appServerLifecycleHandler?(modelContainer)
+ observeAuthNotifications(appServer: appServer, backend: backend, store: store)
if let mcpHTTPServerFactory {
- let mcpHTTPServer = mcpHTTPServerFactory(store, mcpHTTPServerConfiguration)
+ let logProjectionProvider = CodexReviewMCPServer.chatLogProjectionProvider(
+ modelContext: modelContainer.mainContext
+ )
+ let mcpHTTPServer = mcpHTTPServerFactory(
+ store,
+ mcpHTTPServerConfiguration,
+ logProjectionProvider
+ )
try await mcpHTTPServer.start()
startedHTTPServer = mcpHTTPServer
self.mcpHTTPServer = mcpHTTPServer
@@ -434,8 +490,9 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
let failureMessage = await runtimeStartupFailureMessage(for: error)
logger.error("Review runtime failed to start: \(failureMessage, privacy: .public)")
await startedHTTPServer?.stop()
- await startedClient?.close()
- self.client = nil
+ await startedAppServer?.close()
+ self.appServer = nil
+ clearAppServerModelContainer()
self.appServerBackend = nil
self.mcpHTTPServer = nil
authNotificationTask?.cancel()
@@ -469,33 +526,31 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
return message
}
- private func cancelActiveReviewsForRuntimeTeardown(
+ private func cleanupActiveReviewsForRuntimeTeardown(
store: CodexReviewStore,
appServerBackend: AppServerCodexReviewBackend,
reason: ReviewCancellation,
timeoutWarning: String
) async {
- let didInterrupt = await runRuntimeShutdownCleanup(timeout: shutdownCleanupTimeout) {
- await appServerBackend.interruptActiveReviewsForShutdown(reason: .init(message: reason.message))
- }
- let locallyCancelledJobIDs = store.cancelActiveReviewsLocallyForRuntimeStop(
+ let shutdownCleanupTimeout = shutdownCleanupTimeout
+ let cleanupResult = await store.cleanupActiveReviewsForRuntimeStop(
reason: reason,
- cancelWorkers: false
- )
- store.cancelAndDetachReviewWorkersForRuntimeStop(jobIDs: locallyCancelledJobIDs)
- let didDrainReviewWorkers = await store.drainReviewWorkersForRuntimeStop(
- timeout: shutdownCleanupTimeout
- )
- if didInterrupt == false || didDrainReviewWorkers == false {
+ workerDrainTimeout: shutdownCleanupTimeout
+ ) { request in
+ await runRuntimeShutdownCleanup(timeout: shutdownCleanupTimeout) {
+ await appServerBackend.cleanupActiveReviewsForShutdown(request)
+ }
+ }
+ if cleanupResult.didComplete == false {
logger.warning("\(timeoutWarning, privacy: .public)")
}
}
func stop(store: CodexReviewStore) async {
- let client = client
+ let appServer = appServer
let appServerBackend = appServerBackend
let mcpHTTPServer = mcpHTTPServer
- let hasRuntimeState = client != nil || appServerBackend != nil || mcpHTTPServer != nil
+ let hasRuntimeState = appServer != nil || appServerBackend != nil || mcpHTTPServer != nil
let loginCleanup = takeLoginRuntimeForCleanup()
guard hasRuntimeState || loginCleanup.isEmpty == false else {
return
@@ -503,21 +558,22 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
logger.info("Stopping review runtime")
if let appServerBackend {
let reason = ReviewCancellation.system(message: "Review runtime stopped.")
- await cancelActiveReviewsForRuntimeTeardown(
+ await cleanupActiveReviewsForRuntimeTeardown(
store: store,
appServerBackend: appServerBackend,
reason: reason,
timeoutWarning: "Timed out cleaning active reviews before stopping runtime"
)
}
- self.client = nil
+ self.appServer = nil
+ clearAppServerModelContainer()
self.mcpHTTPServer = nil
authNotificationTask?.cancel()
authNotificationTask = nil
await mcpHTTPServer?.stop()
self.appServerBackend = nil
await cleanupLoginRuntime(loginCleanup)
- await client?.close()
+ await appServer?.close()
logger.info("Review runtime stopped")
}
@@ -621,8 +677,8 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
let loginBackend = loginBackend
self.loginBackend = nil
isWaitingForLoginAccountUpdate = false
- let loginClient = loginClient
- self.loginClient = nil
+ let loginAppServer = loginAppServer
+ self.loginAppServer = nil
let loginCodexHomeURL = loginCodexHomeURL
self.loginCodexHomeURL = nil
defer {
@@ -633,7 +689,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
if auth.selectedAccount == nil {
auth.updatePhase(.signedOut)
}
- await closeIsolatedLoginRuntime(client: loginClient, codexHomeURL: loginCodexHomeURL)
+ await closeIsolatedLoginRuntime(appServer: loginAppServer, codexHomeURL: loginCodexHomeURL)
return
}
do {
@@ -642,7 +698,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
} catch {
auth.updatePhase(.failed(message: error.localizedDescription))
}
- await closeIsolatedLoginRuntime(client: loginClient, codexHomeURL: loginCodexHomeURL)
+ await closeIsolatedLoginRuntime(appServer: loginAppServer, codexHomeURL: loginCodexHomeURL)
}
func switchAccount(auth: CodexReviewAuthModel, accountKey: String) async throws {
@@ -774,14 +830,14 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
}
private func startLogin(auth: CodexReviewAuthModel, activation: LoginActivation) async {
- var isolatedLoginClient: AppServerClient?
+ var isolatedLoginAppServer: CodexAppServer?
var isolatedLoginCodexHomeURL: URL?
do {
let runtime = try await loginRuntime(for: activation)
let appServerBackend = runtime.backend
let loginCodexHomeURL = runtime.codexHomeURL
- let loginClient = runtime.usesPrimaryRuntime ? nil : runtime.client
- isolatedLoginClient = loginClient
+ let loginAppServer = runtime.usesPrimaryRuntime ? nil : runtime.appServer
+ isolatedLoginAppServer = loginAppServer
isolatedLoginCodexHomeURL = loginCodexHomeURL
guard runtime.usesPrimaryRuntime || self.appServerBackend != nil else {
logger.error("Cannot start login because review runtime is not running")
@@ -790,7 +846,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
auth: auth,
activation: activation
)
- await closeIsolatedLoginRuntime(client: loginClient, codexHomeURL: loginCodexHomeURL)
+ await closeIsolatedLoginRuntime(appServer: loginAppServer, codexHomeURL: loginCodexHomeURL)
return
}
logger.info("Starting ChatGPT login")
@@ -799,23 +855,28 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
))
loginChallenge = challenge
loginBackend = appServerBackend
- self.loginClient = loginClient
+ self.loginAppServer = loginAppServer
self.loginCodexHomeURL = loginCodexHomeURL
loginActivation = activation
isWaitingForLoginAccountUpdate = false
- if let loginClient {
- observeLoginNotifications(client: loginClient, backend: appServerBackend, auth: auth)
+ if let loginAppServer {
+ observeLoginNotifications(appServer: loginAppServer, backend: appServerBackend, auth: auth)
}
logger.info("Received ChatGPT login challenge")
let nativeCallbackScheme = challenge.nativeWebAuthenticationCallbackScheme
- let usesNativeAuthentication = nativeAuthenticationConfiguration != nil && challenge.verificationURL != nil
+ let usesNativeAuthentication = nativeAuthenticationConfiguration != nil
+ && nativeCallbackScheme != nil
+ && challenge.verificationURL != nil
auth.updatePhase(.signingIn(.init(
title: "Sign in to Codex",
detail: challenge.signInDetail(nativeAuthentication: usesNativeAuthentication),
browserURL: challenge.verificationURL?.absoluteString,
userCode: challenge.userCode
)))
- guard let nativeAuthenticationConfiguration, challenge.verificationURL != nil else {
+ guard usesNativeAuthentication,
+ let nativeAuthenticationConfiguration,
+ challenge.verificationURL != nil
+ else {
if let verificationURL = challenge.verificationURL {
externalURLOpener(verificationURL)
}
@@ -827,14 +888,14 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
try? await appServerBackend.cancelLogin(challenge)
loginChallenge = nil
loginBackend = nil
- self.loginClient = nil
+ self.loginAppServer = nil
self.loginCodexHomeURL = nil
updateAuthenticationFailure(
"Authentication callback is misconfigured.",
auth: auth,
activation: activation
)
- await closeIsolatedLoginRuntime(client: loginClient, codexHomeURL: loginCodexHomeURL)
+ await closeIsolatedLoginRuntime(appServer: loginAppServer, codexHomeURL: loginCodexHomeURL)
return
}
let session = try await webAuthenticationSessionFactory(
@@ -851,7 +912,6 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
await self.monitorAuthenticationSession(
challenge: challenge,
session: session,
- completesLoginThroughCallback: nativeCallbackScheme != nil,
auth: auth
)
}
@@ -862,8 +922,8 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
loginChallenge = nil
loginBackend = nil
isWaitingForLoginAccountUpdate = false
- let loginClient = loginClient ?? isolatedLoginClient
- self.loginClient = nil
+ let loginAppServer = loginAppServer ?? isolatedLoginAppServer
+ self.loginAppServer = nil
let loginCodexHomeURL = loginCodexHomeURL ?? isolatedLoginCodexHomeURL
self.loginCodexHomeURL = nil
activeAuthenticationSession = nil
@@ -874,7 +934,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
if let pendingLoginBackend, let pendingLoginChallenge {
try? await pendingLoginBackend.cancelLogin(pendingLoginChallenge)
}
- await closeIsolatedLoginRuntime(client: loginClient, codexHomeURL: loginCodexHomeURL)
+ await closeIsolatedLoginRuntime(appServer: loginAppServer, codexHomeURL: loginCodexHomeURL)
updateAuthenticationFailure(
error.localizedDescription,
auth: auth,
@@ -886,54 +946,14 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
private func monitorAuthenticationSession(
challenge: CodexReviewBackendModel.Login.Challenge,
session: any CodexReviewNativeAuthentication.WebSession,
- completesLoginThroughCallback: Bool,
auth: CodexReviewAuthModel
) async {
do {
- let callbackURL = try await session.waitForCallbackURL()
+ _ = try await session.waitForCallbackURL()
guard loginChallenge?.id == challenge.id else {
return
}
- guard completesLoginThroughCallback else {
- logger.info("Authentication session completed; waiting for app-server login completion notification")
- return
- }
- guard let loginBackend else {
- return
- }
- let snapshot = try await loginBackend.completeLogin(.init(
- challengeID: challenge.id,
- callbackURL: callbackURL.absoluteString
- ))
- let activation = loginActivation
- let loginClient = loginClient
- let loginCodexHomeURL = loginCodexHomeURL
- loginChallenge = nil
- self.loginBackend = nil
- isWaitingForLoginAccountUpdate = false
- self.loginClient = nil
- self.loginCodexHomeURL = nil
- activeAuthenticationSession = nil
- authenticationTask = nil
- loginNotificationTask?.cancel()
- loginNotificationTask = nil
- let account = applyAuthSnapshot(
- snapshot,
- to: auth,
- activation: activation,
- authSourceCodexHomeURL: loginCodexHomeURL
- )
- await refreshSelectedAccountRateLimits(auth: auth)
- if case .preserveActiveAccount = activation, let account {
- let didRefresh = await refreshRateLimits(for: account, using: loginBackend, source: "login-runtime")
- if didRefresh {
- persistRefreshedSharedAuth(
- from: loginCodexHomeURL,
- for: account
- )
- }
- }
- await closeIsolatedLoginRuntime(client: loginClient, codexHomeURL: loginCodexHomeURL)
+ logger.info("Authentication session completed; waiting for app-server login completion notification")
} catch is CancellationError {
await handleAuthenticationSessionCancelled(challenge: challenge, auth: auth)
} catch CodexReviewNativeAuthenticationError.cancelled {
@@ -943,18 +963,18 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
return
}
logger.error("ChatGPT login failed to complete: \(error.localizedDescription, privacy: .public)")
- let loginClient = loginClient
+ let loginAppServer = loginAppServer
let loginCodexHomeURL = loginCodexHomeURL
loginChallenge = nil
self.loginBackend = nil
isWaitingForLoginAccountUpdate = false
- self.loginClient = nil
+ self.loginAppServer = nil
self.loginCodexHomeURL = nil
activeAuthenticationSession = nil
authenticationTask = nil
loginNotificationTask?.cancel()
loginNotificationTask = nil
- await closeIsolatedLoginRuntime(client: loginClient, codexHomeURL: loginCodexHomeURL)
+ await closeIsolatedLoginRuntime(appServer: loginAppServer, codexHomeURL: loginCodexHomeURL)
updateAuthenticationFailure(
error.localizedDescription,
auth: auth,
@@ -979,11 +999,11 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
private func loginRuntime(for activation: LoginActivation) async throws -> LoginRuntime {
switch activation {
case .activateAuthenticatedAccount:
- guard let client, let appServerBackend else {
+ guard let appServer, let appServerBackend else {
throw CodexReviewAPI.Error.io("Review runtime is not running.")
}
return .init(
- client: client,
+ appServer: appServer,
backend: appServerBackend,
codexHomeURL: codexHomeURL,
usesPrimaryRuntime: true
@@ -993,7 +1013,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
.appendingPathComponent("codex-review-auth-\(UUID().uuidString)", isDirectory: true)
let runtime = try await appServerRuntimeFactory(temporaryCodexHomeURL)
return .init(
- client: runtime.client,
+ appServer: runtime.appServer,
backend: runtime.backend,
codexHomeURL: temporaryCodexHomeURL,
usesPrimaryRuntime: false
@@ -1010,7 +1030,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
}
logger.info("ChatGPT login session was cancelled")
let loginBackend = loginBackend
- let loginClient = loginClient
+ let loginAppServer = loginAppServer
let loginCodexHomeURL = loginCodexHomeURL
if let loginBackend {
do {
@@ -1022,14 +1042,14 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
loginChallenge = nil
self.loginBackend = nil
isWaitingForLoginAccountUpdate = false
- self.loginClient = nil
+ self.loginAppServer = nil
self.loginCodexHomeURL = nil
activeAuthenticationSession = nil
authenticationTask = nil
loginNotificationTask?.cancel()
loginNotificationTask = nil
auth.updatePhase(.signedOut)
- await closeIsolatedLoginRuntime(client: loginClient, codexHomeURL: loginCodexHomeURL)
+ await closeIsolatedLoginRuntime(appServer: loginAppServer, codexHomeURL: loginCodexHomeURL)
}
func startReview(_ request: CodexReviewBackendModel.Review.Start) async throws -> BackendReviewAttempt {
@@ -1046,24 +1066,21 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
try await appServerBackend.interruptReview(run, reason: reason)
}
- func beginReviewRecovery(
- _ run: CodexReviewBackendModel.Review.Run,
- reason: CodexReviewBackendModel.CancellationReason
- ) async throws -> CodexReviewBackendModel.Review.RecoveryToken {
+ func prepareReviewRestart(_ run: CodexReviewBackendModel.Review.Run) async throws -> CodexReviewBackendModel.Review.RestartToken {
guard let appServerBackend else {
throw CodexReviewAPI.Error.io("Review runtime is not running.")
}
- return try await appServerBackend.beginReviewRecovery(run, reason: reason)
+ return try await appServerBackend.prepareReviewRestart(run)
}
- func resumeReviewRecovery(
- _ token: CodexReviewBackendModel.Review.RecoveryToken,
+ func restartPreparedReview(
+ _ token: CodexReviewBackendModel.Review.RestartToken,
request: CodexReviewBackendModel.Review.Start
) async throws -> BackendReviewAttempt {
guard let appServerBackend else {
throw CodexReviewAPI.Error.io("Review runtime is not running.")
}
- return try await appServerBackend.resumeReviewRecovery(token, request: request)
+ return try await appServerBackend.restartPreparedReview(token, request: request)
}
func cleanupReview(_ run: CodexReviewBackendModel.Review.Run) async {
@@ -1079,7 +1096,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
to auth: CodexReviewAuthModel,
activation: LoginActivation = .activateAuthenticatedAccount,
authSourceCodexHomeURL: URL? = nil
- ) -> CodexAccount? {
+ ) -> CodexReviewAccount? {
guard let activeAccountID = snapshot.activeAccountID?.rawValue,
let backendAccount = snapshot.accounts.first(where: { $0.id.rawValue == activeAccountID }),
let account = Self.monitorAccount(from: backendAccount)
@@ -1093,7 +1110,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
return nil
}
var persistedAccounts = auth.persistedAccounts
- let persistedAccount: CodexAccount
+ let persistedAccount: CodexReviewAccount
if let index = persistedAccounts.firstIndex(where: { $0.accountKey == account.accountKey }) {
persistedAccounts[index].updateEmail(account.email)
persistedAccounts[index].updateKind(account.kind, capabilities: account.capabilities)
@@ -1137,7 +1154,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
}
private func observeAuthNotifications(
- client: AppServerClient,
+ appServer: CodexAppServer,
backend: AppServerCodexReviewBackend,
store: CodexReviewStore
) {
@@ -1146,11 +1163,11 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
guard let self, let store else {
return
}
- let stream = await client.notificationStream()
+ let stream = await appServer.accountEvents()
do {
- for try await notification in stream {
+ for try await event in stream {
await self.handleAuthNotification(
- notification,
+ event,
backend: backend,
auth: store.auth
)
@@ -1163,55 +1180,66 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
}
}
+ // Dropping the container must always reach the lifecycle handler, or the
+ // ReviewMonitor window keeps its model source pointed at a closed
+ // app-server container.
+ private func clearAppServerModelContainer() {
+ appServerModelContainer = nil
+ appServerLifecycleHandler?(nil)
+ }
+
private func markRuntimeFailedAfterNotificationStreamError(
_ error: any Error,
store: CodexReviewStore
) async {
let loginCleanup = takeLoginRuntimeForCleanup()
- guard client != nil || appServerBackend != nil || mcpHTTPServer != nil || loginCleanup.isEmpty == false else {
+ guard appServer != nil || appServerBackend != nil || mcpHTTPServer != nil || loginCleanup.isEmpty == false else {
return
}
let message = "Review runtime stopped unexpectedly: \(error.localizedDescription)"
if let appServerBackend {
let reason = ReviewCancellation.system(message: message)
- await cancelActiveReviewsForRuntimeTeardown(
+ await cleanupActiveReviewsForRuntimeTeardown(
store: store,
appServerBackend: appServerBackend,
reason: reason,
timeoutWarning: "Timed out cleaning active reviews after runtime failure"
)
}
- let failedClient = client
+ let failedAppServer = appServer
let failedMCPHTTPServer = mcpHTTPServer
- client = nil
+ appServer = nil
+ clearAppServerModelContainer()
appServerBackend = nil
mcpHTTPServer = nil
authNotificationTask = nil
store.transitionToFailed(message)
await failedMCPHTTPServer?.stop()
await cleanupLoginRuntime(loginCleanup)
- await failedClient?.close()
+ await failedAppServer?.close()
}
private func handleAuthNotification(
- _ notification: JSONRPC.Notification,
+ _ event: CodexAccountEvent,
backend: AppServerCodexReviewBackend,
auth: CodexReviewAuthModel
) async {
- switch notification.method {
- case "account/login/completed":
- await handleLoginCompletedNotification(notification, backend: backend, auth: auth)
- case "account/updated":
+ switch event {
+ case .loginCompleted(let completion):
+ await handleLoginCompletedNotification(completion, backend: backend, auth: auth)
+ case .accountUpdated:
await handleAccountUpdatedNotification(backend: backend, auth: auth)
- case "account/rateLimits/updated":
- await applyRateLimitsUpdatedNotification(notification, auth: auth)
- default:
+ case .rateLimitsUpdated(let rateLimits):
+ await applyRateLimitsUpdatedNotification(rateLimits, auth: auth)
+ case .malformed(let method, let message):
+ logger.error("Malformed account notification \(method, privacy: .public): \(message, privacy: .public)")
+ case .unknown:
return
}
}
private func observeLoginNotifications(
- client: AppServerClient,
+ appServer: CodexAppServer,
backend: AppServerCodexReviewBackend,
auth: CodexReviewAuthModel
) {
@@ -1220,13 +1248,10 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
guard let self, let auth else {
return
}
- let stream = await client.notificationStream()
+ let stream = await appServer.accountEvents()
do {
- for try await notification in stream
- where notification.method == "account/login/completed"
- || notification.method == "account/updated"
- {
- await self.handleLoginRuntimeNotification(notification, backend: backend, auth: auth)
+ for try await event in stream {
+ await self.handleLoginRuntimeNotification(event, backend: backend, auth: auth)
}
} catch is CancellationError {
} catch {
@@ -1236,65 +1261,58 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
}
private func handleLoginRuntimeNotification(
- _ notification: JSONRPC.Notification,
+ _ event: CodexAccountEvent,
backend: AppServerCodexReviewBackend,
auth: CodexReviewAuthModel
) async {
- switch notification.method {
- case "account/login/completed":
- await handleLoginCompletedNotification(notification, backend: backend, auth: auth)
- case "account/updated":
+ switch event {
+ case .loginCompleted(let completion):
+ await handleLoginCompletedNotification(completion, backend: backend, auth: auth)
+ case .accountUpdated:
guard loginBackend != nil, isWaitingForLoginAccountUpdate else {
return
}
await finishCompletedLoginAfterAccountUpdate(backend: backend, auth: auth)
- default:
+ case .rateLimitsUpdated,
+ .malformed,
+ .unknown:
return
}
}
private func handleLoginCompletedNotification(
- _ notification: JSONRPC.Notification,
+ _ completion: CodexLoginCompletion,
backend: AppServerCodexReviewBackend,
auth: CodexReviewAuthModel
) async {
- guard notification.method == "account/login/completed" else {
- await handleAccountUpdatedNotification(backend: backend, auth: auth)
+ guard completion.loginID?.rawValue == nil || completion.loginID?.rawValue == loginChallenge?.id else {
return
}
- do {
- let payload = try JSONDecoder().decode(AppServerAccountLoginCompletedNotification.self, from: notification.params)
- guard payload.loginID == nil || payload.loginID == loginChallenge?.id else {
- return
- }
- loginChallenge = nil
- let loginClient = loginClient
- let loginCodexHomeURL = loginCodexHomeURL
- let activeAuthenticationSession = activeAuthenticationSession
- self.activeAuthenticationSession = nil
- authenticationTask?.cancel()
- authenticationTask = nil
- await activeAuthenticationSession?.cancel()
- guard payload.success else {
- updateAuthenticationFailure(
- payload.error ?? "Authentication failed.",
- auth: auth,
- activation: loginActivation
- )
- self.loginBackend = nil
- isWaitingForLoginAccountUpdate = false
- self.loginClient = nil
- self.loginCodexHomeURL = nil
- loginNotificationTask?.cancel()
- loginNotificationTask = nil
- await closeIsolatedLoginRuntime(client: loginClient, codexHomeURL: loginCodexHomeURL)
- return
- }
- isWaitingForLoginAccountUpdate = true
- logger.info("ChatGPT login completed; waiting for account update notification")
- } catch {
- logger.error("Failed to decode account login completion: \(error.localizedDescription, privacy: .public)")
+ loginChallenge = nil
+ let loginAppServer = loginAppServer
+ let loginCodexHomeURL = loginCodexHomeURL
+ let activeAuthenticationSession = activeAuthenticationSession
+ self.activeAuthenticationSession = nil
+ authenticationTask?.cancel()
+ authenticationTask = nil
+ await activeAuthenticationSession?.cancel()
+ guard completion.success else {
+ updateAuthenticationFailure(
+ completion.error ?? "Authentication failed.",
+ auth: auth,
+ activation: loginActivation
+ )
+ self.loginBackend = nil
+ isWaitingForLoginAccountUpdate = false
+ self.loginAppServer = nil
+ self.loginCodexHomeURL = nil
+ loginNotificationTask?.cancel()
+ loginNotificationTask = nil
+ await closeIsolatedLoginRuntime(appServer: loginAppServer, codexHomeURL: loginCodexHomeURL)
+ return
}
+ isWaitingForLoginAccountUpdate = true
+ logger.info("ChatGPT login completed; waiting for account update notification")
}
private func handleAccountUpdatedNotification(
@@ -1314,7 +1332,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
) async {
let activation = loginActivation
let loginBackend = loginBackend
- let loginClient = loginClient
+ let loginAppServer = loginAppServer
let loginCodexHomeURL = loginCodexHomeURL
let activeAuthenticationSession = activeAuthenticationSession
do {
@@ -1348,12 +1366,12 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
)
}
self.loginBackend = nil
- self.loginClient = nil
+ self.loginAppServer = nil
self.loginCodexHomeURL = nil
isWaitingForLoginAccountUpdate = false
loginNotificationTask?.cancel()
loginNotificationTask = nil
- await closeIsolatedLoginRuntime(client: loginClient, codexHomeURL: loginCodexHomeURL)
+ await closeIsolatedLoginRuntime(appServer: loginAppServer, codexHomeURL: loginCodexHomeURL)
}
private func refreshAuthAfterAccountNotification(
@@ -1369,33 +1387,20 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
}
private func applyRateLimitsUpdatedNotification(
- _ notification: JSONRPC.Notification,
+ _ rateLimits: CodexRateLimits,
auth: CodexReviewAuthModel
) async {
- do {
- let payload = try JSONDecoder().decode(AppServerAccountRateLimitsUpdatedPayload.self, from: notification.params)
- guard let selectedAccount = auth.selectedAccount else {
- return
- }
- guard selectedAccount.capabilities.supportsRateLimitRefresh else {
- return
- }
- guard AppServerAPI.Account.RateLimits.Response.isCodexRateLimit(payload.rateLimits.limitID) else {
- return
- }
- let response = AppServerAPI.Account.RateLimits.Response(rateLimits: payload.rateLimits)
- applyRateLimits(
- windows: response.codexRateLimitWindows,
- planType: response.codexPlanType,
- to: selectedAccount
- )
- try? CodexReviewAccountRegistry.updateCachedRateLimits(
- from: selectedAccount,
- codexHomeURL: codexHomeURL
- )
- } catch {
- logger.error("Failed to decode account rate limit update: \(error.localizedDescription, privacy: .public)")
+ guard let selectedAccount = auth.selectedAccount else {
+ return
+ }
+ guard selectedAccount.capabilities.supportsRateLimitRefresh else {
+ return
}
+ applyRateLimits(rateLimits, to: selectedAccount)
+ try? CodexReviewAccountRegistry.updateCachedRateLimits(
+ from: selectedAccount,
+ codexHomeURL: codexHomeURL
+ )
}
private func refreshSelectedAccountRateLimits(auth: CodexReviewAuthModel) async {
@@ -1405,7 +1410,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
await refreshRateLimits(for: selectedAccount, auth: auth)
}
- private func refreshRateLimits(for account: CodexAccount, auth: CodexReviewAuthModel) async {
+ private func refreshRateLimits(for account: CodexReviewAccount, auth: CodexReviewAuthModel) async {
guard account.capabilities.supportsRateLimitRefresh else {
return
}
@@ -1422,7 +1427,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
}
}
- private func refreshSavedAccountRateLimits(for account: CodexAccount) async {
+ private func refreshSavedAccountRateLimits(for account: CodexReviewAccount) async {
let temporaryCodexHomeURL = FileManager.default.temporaryDirectory
.appendingPathComponent("codex-review-rate-limits-\(UUID().uuidString)", isDirectory: true)
do {
@@ -1452,10 +1457,10 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
)
}
} catch {
- await closeIsolatedLoginRuntime(client: runtime.client, codexHomeURL: temporaryCodexHomeURL)
+ await closeIsolatedLoginRuntime(appServer: runtime.appServer, codexHomeURL: temporaryCodexHomeURL)
throw error
}
- await closeIsolatedLoginRuntime(client: runtime.client, codexHomeURL: temporaryCodexHomeURL)
+ await closeIsolatedLoginRuntime(appServer: runtime.appServer, codexHomeURL: temporaryCodexHomeURL)
} catch {
try? FileManager.default.removeItem(at: temporaryCodexHomeURL)
account.updateRateLimitFetchMetadata(fetchedAt: Date(), error: error.localizedDescription)
@@ -1467,7 +1472,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
}
private func refreshRateLimits(
- for account: CodexAccount,
+ for account: CodexReviewAccount,
using backend: AppServerCodexReviewBackend?,
source: String
) async -> Bool {
@@ -1482,11 +1487,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
)
}
let response = try await backend.readRateLimits()
- applyRateLimits(
- windows: response.codexRateLimitWindows,
- planType: response.codexPlanType,
- to: account
- )
+ applyRateLimits(response, to: account)
try? CodexReviewAccountRegistry.updateCachedRateLimits(
from: account,
codexHomeURL: codexHomeURL
@@ -1503,14 +1504,14 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
}
private func validateRateLimitBackendAccount(
- _ account: CodexAccount,
+ _ account: CodexReviewAccount,
using backend: AppServerCodexReviewBackend
) async throws {
let snapshot = try await backend.readAuth()
guard let activeAccountID = snapshot.activeAccountID?.rawValue.nilIfEmpty else {
throw CodexReviewAPI.Error.io("Saved authentication is missing for \(account.maskedEmail). Sign in again.")
}
- let actualAccountKey = CodexAccount.normalizedEmail(activeAccountID)
+ let actualAccountKey = CodexReviewAccount.normalizedEmail(activeAccountID)
guard actualAccountKey == account.accountKey else {
let actualEmail = snapshot.accounts.first(where: { $0.id.rawValue == activeAccountID })?.label
?? activeAccountID
@@ -1521,10 +1522,10 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
private func recordRateLimitRefreshFailure(
_ error: any Error,
- account: CodexAccount
+ account: CodexReviewAccount
) {
let message = error.localizedDescription
- if CodexAccount.requiresReauthentication(errorMessage: message) {
+ if CodexReviewAccount.requiresReauthentication(errorMessage: message) {
account.markRateLimitReauthenticationRequired(
fetchedAt: Date(),
error: message
@@ -1534,15 +1535,15 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
}
}
- private func closeIsolatedLoginRuntime(client: AppServerClient?, codexHomeURL: URL?) async {
+ private func closeIsolatedLoginRuntime(appServer: CodexAppServer?, codexHomeURL: URL?) async {
guard let codexHomeURL else {
- await client?.close()
+ await appServer?.close()
return
}
guard codexHomeURL != self.codexHomeURL else {
return
}
- await client?.close()
+ await appServer?.close()
try? FileManager.default.removeItem(at: codexHomeURL)
}
@@ -1550,8 +1551,8 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
loginChallenge = nil
loginBackend = nil
isWaitingForLoginAccountUpdate = false
- let loginClient = loginClient
- self.loginClient = nil
+ let loginAppServer = loginAppServer
+ self.loginAppServer = nil
let loginCodexHomeURL = loginCodexHomeURL
self.loginCodexHomeURL = nil
let activeAuthenticationSession = activeAuthenticationSession
@@ -1561,7 +1562,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
loginNotificationTask?.cancel()
loginNotificationTask = nil
return .init(
- client: loginClient,
+ appServer: loginAppServer,
codexHomeURL: loginCodexHomeURL,
authenticationSession: activeAuthenticationSession
)
@@ -1569,16 +1570,21 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
private func cleanupLoginRuntime(_ cleanup: PendingLoginRuntimeCleanup) async {
await cleanup.authenticationSession?.cancel()
- await closeIsolatedLoginRuntime(client: cleanup.client, codexHomeURL: cleanup.codexHomeURL)
+ await closeIsolatedLoginRuntime(appServer: cleanup.appServer, codexHomeURL: cleanup.codexHomeURL)
}
private func applyRateLimits(
- windows: [(windowDurationMinutes: Int, usedPercent: Int, resetsAt: Date?)],
- planType: String?,
- to account: CodexAccount
+ _ rateLimits: CodexRateLimits,
+ to account: CodexReviewAccount
) {
- account.updateRateLimits(windows)
- if let planType {
+ account.updateRateLimits(rateLimits.windows.map {
+ (
+ windowDurationMinutes: $0.windowDurationMinutes,
+ usedPercent: $0.usedPercent,
+ resetsAt: $0.resetsAt
+ )
+ })
+ if let planType = rateLimits.planType {
account.updatePlanType(planType)
}
account.updateRateLimitFetchMetadata(fetchedAt: Date(), error: nil)
@@ -1586,7 +1592,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
private func persistRefreshedSharedAuth(
from sourceCodexHomeURL: URL?,
- for account: CodexAccount
+ for account: CodexReviewAccount
) {
guard let sourceCodexHomeURL else {
return
@@ -1635,13 +1641,13 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
)
}
- private static func monitorAccount(from snapshot: CodexReviewBackendModel.Account.Snapshot) -> CodexAccount? {
+ private static func monitorAccount(from snapshot: CodexReviewBackendModel.Account.Snapshot) -> CodexReviewAccount? {
let label = snapshot.label.trimmingCharacters(in: .whitespacesAndNewlines)
- let accountKey = CodexAccount.normalizedEmail(snapshot.id.rawValue)
+ let accountKey = CodexReviewAccount.normalizedEmail(snapshot.id.rawValue)
guard label.isEmpty == false, accountKey.isEmpty == false else {
return nil
}
- return CodexAccount(
+ return CodexReviewAccount(
accountKey: accountKey,
email: label,
planType: snapshot.planType,
@@ -1660,18 +1666,14 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
@MainActor
private struct AppServerRuntime: Sendable {
- var client: AppServerClient
+ var appServer: CodexAppServer
+ var modelContainer: CodexModelContainer
var backend: AppServerCodexReviewBackend
}
-private struct AppServerProcessRuntime: Sendable {
- var transport: AppServerProcessTransport
- var threadStartPermissionStrategy: AppServerAPI.Thread.Start.PermissionStrategy
-}
-
@MainActor
private struct LoginRuntime: Sendable {
- var client: AppServerClient
+ var appServer: CodexAppServer
var backend: AppServerCodexReviewBackend
var codexHomeURL: URL
var usesPrimaryRuntime: Bool
@@ -1683,7 +1685,7 @@ private enum LoginActivation: Equatable, Sendable {
func resolvedActiveAccountKey(
authenticatedAccountKey: String,
- persistedAccounts: [CodexAccount]
+ persistedAccounts: [CodexReviewAccount]
) -> String? {
switch self {
case .activateAuthenticatedAccount:
@@ -1700,22 +1702,6 @@ private enum LoginActivation: Equatable, Sendable {
private typealias AppServerRuntimeFactory = @MainActor @Sendable (URL) async throws -> AppServerRuntime
-private struct AppServerAccountLoginCompletedNotification: Decodable, Equatable, Sendable {
- var error: String?
- var loginID: String?
- var success: Bool
-
- enum CodingKeys: String, CodingKey {
- case error
- case loginID = "loginId"
- case success
- }
-}
-
-private struct AppServerAccountRateLimitsUpdatedPayload: Decodable, Equatable, Sendable {
- var rateLimits: AppServerAPI.Account.RateLimits.Snapshot
-}
-
@MainActor
private enum CodexReviewAccountRegistry {
private struct Registry: Codable {
@@ -1768,6 +1754,8 @@ private enum CodexReviewAccountRegistry {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.accountKey = try container.decodeIfPresent(String.self, forKey: .accountKey)
self.email = try container.decode(String.self, forKey: .email)
+ // Registries written before the kind field existed must keep
+ // decoding; dropping them would empty the persisted account list.
self.kind = try container.decodeIfPresent(Kind.self, forKey: .kind)
?? Kind.legacyDefault(accountKey: accountKey, email: email)
self.planType = try container.decodeIfPresent(String.self, forKey: .planType)
@@ -1786,6 +1774,20 @@ private enum CodexReviewAccountRegistry {
case apiKey
case amazonBedrock
+ static func legacyDefault(accountKey: String?, email: String) -> Self {
+ let normalizedAccountKey = accountKey
+ .map(CodexReviewAccount.normalizedEmail)
+ .flatMap { $0.isEmpty ? nil : $0 }
+ switch normalizedAccountKey ?? CodexReviewAccount.normalizedEmail(email) {
+ case "api-key":
+ return .apiKey
+ case "amazon-bedrock":
+ return .amazonBedrock
+ default:
+ return .chatGPT
+ }
+ }
+
init(_ accountKind: CodexReviewBackendModel.Account.Kind) {
switch accountKind {
case .chatGPT:
@@ -1808,19 +1810,6 @@ private enum CodexReviewAccountRegistry {
}
}
- static func legacyDefault(accountKey: String?, email: String) -> Self {
- let normalizedAccountKey = accountKey
- .map(CodexAccount.normalizedEmail)
- .flatMap { $0.isEmpty ? nil : $0 }
- switch normalizedAccountKey ?? CodexAccount.normalizedEmail(email) {
- case "api-key":
- return .apiKey
- case "amazon-bedrock":
- return .amazonBedrock
- default:
- return .chatGPT
- }
- }
}
private struct SavedRateLimitWindow: Codable {
@@ -1833,11 +1822,11 @@ private enum CodexReviewAccountRegistry {
}
}
- static func load(codexHomeURL: URL) -> (accounts: [CodexAccount], activeAccountKey: String?) {
+ static func load(codexHomeURL: URL) -> (accounts: [CodexReviewAccount], activeAccountKey: String?) {
let registry = loadRegistry(codexHomeURL: codexHomeURL)
let accounts = registry.accounts.compactMap(makeAccount(from:))
let activeAccountKey = registry.activeAccountKey
- .map(CodexAccount.normalizedEmail)
+ .map(CodexReviewAccount.normalizedEmail)
.flatMap { activeAccountKey in
accounts.contains(where: { $0.accountKey == activeAccountKey }) ? activeAccountKey : nil
}
@@ -1846,7 +1835,7 @@ private enum CodexReviewAccountRegistry {
}
static func saveAccounts(
- _ accounts: [CodexAccount],
+ _ accounts: [CodexReviewAccount],
activeAccountKey: String?,
codexHomeURL: URL
) throws {
@@ -1855,7 +1844,7 @@ private enum CodexReviewAccountRegistry {
normalizedAccountKey(from: entry).map { ($0, entry) }
})
let normalizedActiveAccountKey = activeAccountKey
- .map(CodexAccount.normalizedEmail)
+ .map(CodexReviewAccount.normalizedEmail)
.flatMap { accountKey in
accounts.contains(where: { $0.accountKey == accountKey }) ? accountKey : nil
}
@@ -1896,10 +1885,10 @@ private enum CodexReviewAccountRegistry {
static func activateAccount(
_ accountKey: String,
- accounts: [CodexAccount],
+ accounts: [CodexReviewAccount],
codexHomeURL: URL
) throws {
- let normalizedAccountKey = CodexAccount.normalizedEmail(accountKey)
+ let normalizedAccountKey = CodexReviewAccount.normalizedEmail(accountKey)
let savedAuthURL = savedAccountAuthURL(
accountKey: normalizedAccountKey,
codexHomeURL: codexHomeURL
@@ -1916,7 +1905,7 @@ private enum CodexReviewAccountRegistry {
}
static func updateCachedRateLimits(
- from account: CodexAccount,
+ from account: CodexReviewAccount,
codexHomeURL: URL
) throws {
var registry = loadRegistry(codexHomeURL: codexHomeURL)
@@ -1939,7 +1928,7 @@ private enum CodexReviewAccountRegistry {
}
static func saveSharedAuth(
- for account: CodexAccount,
+ for account: CodexReviewAccount,
codexHomeURL: URL
) throws {
try saveSharedAuth(
@@ -1951,7 +1940,7 @@ private enum CodexReviewAccountRegistry {
static func saveSharedAuth(
from sourceCodexHomeURL: URL,
- for account: CodexAccount,
+ for account: CodexReviewAccount,
codexHomeURL: URL
) throws {
let sourceURL = sharedAuthURL(codexHomeURL: sourceCodexHomeURL)
@@ -1988,7 +1977,7 @@ private enum CodexReviewAccountRegistry {
from sourceCodexHomeURL: URL,
to destinationCodexHomeURL: URL
) throws -> Bool {
- let normalizedAccountKey = CodexAccount.normalizedEmail(accountKey)
+ let normalizedAccountKey = CodexReviewAccount.normalizedEmail(accountKey)
let sourceURL = savedAccountAuthURL(
accountKey: normalizedAccountKey,
codexHomeURL: sourceCodexHomeURL
@@ -2003,17 +1992,17 @@ private enum CodexReviewAccountRegistry {
return true
}
- private static func makeAccount(from entry: Entry) -> CodexAccount? {
+ private static func makeAccount(from entry: Entry) -> CodexReviewAccount? {
let email = entry.email.trimmingCharacters(in: .whitespacesAndNewlines)
guard email.isEmpty == false else {
return nil
}
- let normalizedEmail = CodexAccount.normalizedEmail(email)
+ let normalizedEmail = CodexReviewAccount.normalizedEmail(email)
let accountKey = entry.accountKey
- .map(CodexAccount.normalizedEmail)
+ .map(CodexReviewAccount.normalizedEmail)
.flatMap { $0.isEmpty ? nil : $0 }
?? normalizedEmail
- let account = CodexAccount(
+ let account = CodexReviewAccount(
accountKey: accountKey,
email: email,
planType: entry.planType,
@@ -2079,9 +2068,9 @@ private enum CodexReviewAccountRegistry {
private static func normalizedAccountKey(from entry: Entry) -> String? {
let email = entry.email.trimmingCharacters(in: .whitespacesAndNewlines)
- let normalizedEmail = CodexAccount.normalizedEmail(email)
+ let normalizedEmail = CodexReviewAccount.normalizedEmail(email)
return entry.accountKey
- .map(CodexAccount.normalizedEmail)
+ .map(CodexReviewAccount.normalizedEmail)
.flatMap { $0.isEmpty ? nil : $0 }
?? (normalizedEmail.isEmpty ? nil : normalizedEmail)
}
@@ -2110,7 +2099,7 @@ private enum CodexReviewAccountRegistry {
}
private static func pathComponent(forAccountKey accountKey: String) -> String {
- let normalizedAccountKey = CodexAccount.normalizedEmail(accountKey)
+ let normalizedAccountKey = CodexReviewAccount.normalizedEmail(accountKey)
switch normalizedAccountKey {
case ".":
return "%2E"
diff --git a/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift b/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift
new file mode 100644
index 00000000..6ddb464b
--- /dev/null
+++ b/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift
@@ -0,0 +1,212 @@
+import Foundation
+
+package struct ReviewBackendEventSessionMetrics: Equatable, Sendable {
+ package var routed = 0
+ package var decoded = 0
+ package var emitted = 0
+ package var ignored = 0
+ package var buffered = 0
+ package var commandTimeoutWarnings = 0
+ package var firstEventLatencyMs: Int?
+ package var terminalLatencyMs: Int?
+
+ package init() {}
+}
+
+package struct ReviewBackendEventSessionCallbacks: Sendable {
+ package var recordTurnStarted: @Sendable (_ turnID: String) async -> Void
+ package var recordFinished:
+ @Sendable (
+ _ run: CodexReviewBackendModel.Review.Run,
+ _ metrics: ReviewBackendEventSessionMetrics
+ ) async -> Void
+
+ package init(
+ recordTurnStarted: @escaping @Sendable (_ turnID: String) async -> Void = { _ in },
+ recordFinished:
+ @escaping @Sendable (
+ _ run: CodexReviewBackendModel.Review.Run,
+ _ metrics: ReviewBackendEventSessionMetrics
+ ) async -> Void = { _, _ in }
+ ) {
+ self.recordTurnStarted = recordTurnStarted
+ self.recordFinished = recordFinished
+ }
+}
+
+package actor ReviewBackendEventSession {
+ private var run: CodexReviewBackendModel.Review.Run
+ private let mailbox: BackendReviewEventMailbox
+ private let callbacks: ReviewBackendEventSessionCallbacks
+ private var reviewThreadIDsForCleanup: [String] = []
+ private var cancellationRequestedMessage: String?
+ private let createdAt = Date()
+ private var finished = false
+ private var metrics = ReviewBackendEventSessionMetrics()
+
+ package init(
+ run: CodexReviewBackendModel.Review.Run,
+ mailbox: BackendReviewEventMailbox = .init(),
+ callbacks: ReviewBackendEventSessionCallbacks = .init()
+ ) {
+ self.run = run
+ self.mailbox = mailbox
+ self.callbacks = callbacks
+ if let reviewThreadID = run.reviewThreadID?.nilIfEmpty,
+ reviewThreadID != run.threadID
+ {
+ self.reviewThreadIDsForCleanup.append(reviewThreadID)
+ }
+ }
+
+ package func updateRun(_ run: CodexReviewBackendModel.Review.Run) {
+ self.run = run
+ noteReviewThreadIDForCleanup(run.reviewThreadID)
+ }
+
+ package func currentRun() -> CodexReviewBackendModel.Review.Run {
+ run
+ }
+
+ package func attempt() -> BackendReviewAttempt {
+ .init(run: run, events: mailbox)
+ }
+
+ package func cleanupThreadIDs() -> [String] {
+ var threadIDs = reviewThreadIDsForCleanup.filter { $0 != run.threadID }
+ threadIDs.append(run.threadID)
+ return threadIDs
+ }
+
+ package func requestCancellation(message: String) {
+ cancellationRequestedMessage = message
+ }
+
+ package func clearCancellationRequest() {
+ cancellationRequestedMessage = nil
+ }
+
+ package func finish(cancellationMessage: String?) async {
+ cancellationRequestedMessage = cancellationMessage
+ await finishSession(cancellationMessage: cancellationMessage)
+ }
+
+ package func finish(throwing error: (any Error)?) async {
+ guard finished == false else {
+ return
+ }
+ if let error {
+ finished = true
+ await mailbox.fail(error)
+ } else {
+ finished = true
+ await mailbox.finish()
+ }
+ }
+
+ package func abandon() async {
+ guard finished == false else {
+ return
+ }
+ finished = true
+ await mailbox.abandon()
+ }
+
+ package func metricsSnapshot() -> ReviewBackendEventSessionMetrics {
+ metrics
+ }
+
+ package func receive(
+ _ events: [CodexReviewBackendModel.Review.Event],
+ controlThreadID: String? = nil
+ ) async {
+ metrics.routed += 1
+ guard finished == false else {
+ metrics.ignored += 1
+ return
+ }
+ guard events.isEmpty == false else {
+ metrics.ignored += 1
+ return
+ }
+ metrics.decoded += 1
+ for event in events {
+ if await emit(event, controlThreadID: controlThreadID) {
+ return
+ }
+ }
+ }
+
+ private func finishSession(cancellationMessage: String?) async {
+ guard finished == false else {
+ return
+ }
+ if let cancellationMessage {
+ _ = await emit(.cancelled(cancellationMessage))
+ } else {
+ await mailbox.finish()
+ }
+ finished = true
+ }
+
+ private func noteReviewThreadIDForCleanup(_ reviewThreadID: String?) {
+ guard let reviewThreadID = reviewThreadID?.nilIfEmpty,
+ reviewThreadID != run.threadID,
+ reviewThreadIDsForCleanup.contains(reviewThreadID) == false
+ else {
+ return
+ }
+ reviewThreadIDsForCleanup.append(reviewThreadID)
+ }
+
+ private func emit(
+ _ event: CodexReviewBackendModel.Review.Event,
+ controlThreadID: String? = nil
+ ) async -> Bool {
+ noteEmission(event)
+ await mailbox.append(event)
+ await recordReviewEvent(event, controlThreadID: controlThreadID)
+ return event.isReviewBackendTerminal
+ }
+
+ private func recordReviewEvent(
+ _ event: CodexReviewBackendModel.Review.Event,
+ controlThreadID _: String? = nil
+ ) async {
+ switch event {
+ case .started(let turnID, _, _):
+ await callbacks.recordTurnStarted(turnID)
+ case .completed, .failed, .cancelled:
+ await callbacks.recordFinished(run, metrics)
+ }
+ }
+
+ private func noteEmission(_ event: CodexReviewBackendModel.Review.Event) {
+ metrics.emitted += 1
+ if metrics.firstEventLatencyMs == nil {
+ metrics.firstEventLatencyMs = Self.durationMs(from: createdAt, to: Date())
+ }
+ if event.isReviewBackendTerminal {
+ metrics.terminalLatencyMs = Self.durationMs(from: createdAt, to: Date())
+ }
+ }
+
+ private static func durationMs(from start: Date, to end: Date) -> Int {
+ let milliseconds = end.timeIntervalSince(start) * 1000
+ guard milliseconds.isFinite else {
+ return 0
+ }
+ return max(0, Int(milliseconds.rounded()))
+ }
+}
+
+private extension CodexReviewBackendModel.Review.Event {
+ var isReviewBackendTerminal: Bool {
+ switch self {
+ case .completed, .failed, .cancelled:
+ true
+ case .started:
+ false
+ }
+ }
+}
diff --git a/Sources/CodexReviewApplication/ReviewObservationAwaiter.swift b/Sources/CodexReviewKit/Application/ReviewObservationAwaiter.swift
similarity index 82%
rename from Sources/CodexReviewApplication/ReviewObservationAwaiter.swift
rename to Sources/CodexReviewKit/Application/ReviewObservationAwaiter.swift
index f96d560a..065ad046 100644
--- a/Sources/CodexReviewApplication/ReviewObservationAwaiter.swift
+++ b/Sources/CodexReviewKit/Application/ReviewObservationAwaiter.swift
@@ -1,14 +1,13 @@
import Foundation
-import CodexReviewDomain
import ObservationBridge
@MainActor
-public enum ReviewObservationAwaiter {
- public static func waitUntilTerminal(
- timeline: ReviewTimeline,
+package enum ReviewObservationAwaiter {
+ package static func waitUntilTerminal(
+ run: ReviewRunRecord,
timeout: Duration? = nil
) async -> Bool {
- if timeline.activeItemIDs.isEmpty, timeline.isTerminal {
+ if run.isTerminal {
return true
}
@@ -16,7 +15,7 @@ public enum ReviewObservationAwaiter {
return await withTaskCancellationHandler {
await withCheckedContinuation { continuation in
waiter.begin(
- timeline: timeline,
+ run: run,
timeout: timeout,
continuation: continuation
)
@@ -37,14 +36,14 @@ private final class ReviewTerminalObservationWaiter {
private var isResolved = false
func begin(
- timeline: ReviewTimeline,
+ run: ReviewRunRecord,
timeout: Duration?,
continuation: CheckedContinuation
) {
self.continuation = continuation
- token = withPortableContinuousObservation { [weak self, timeline] event in
- _ = timeline.revision
- guard timeline.activeItemIDs.isEmpty, timeline.isTerminal else {
+ token = withPortableContinuousObservation { [weak self, run] event in
+ _ = run.core.lifecycle.status
+ guard run.isTerminal else {
return
}
event.cancel()
diff --git a/Sources/CodexReview/CodexReviewBackend.swift b/Sources/CodexReviewKit/CodexReviewBackend.swift
similarity index 86%
rename from Sources/CodexReview/CodexReviewBackend.swift
rename to Sources/CodexReviewKit/CodexReviewBackend.swift
index bd1c7eaf..f891574e 100644
--- a/Sources/CodexReview/CodexReviewBackend.swift
+++ b/Sources/CodexReviewKit/CodexReviewBackend.swift
@@ -2,22 +2,22 @@ import Foundation
package protocol CodexReviewBackend: Sendable {
func readSettings() async throws -> CodexReviewBackendModel.Settings.Snapshot
- func applySettings(_ change: CodexReviewBackendModel.Settings.Change) async throws -> CodexReviewBackendModel.Settings.Snapshot
+ func applySettings(_ change: CodexReviewBackendModel.Settings.Change) async throws
+ -> CodexReviewBackendModel.Settings.Snapshot
func readAuth() async throws -> CodexReviewBackendModel.Auth.Snapshot
- func startLogin(_ request: CodexReviewBackendModel.Login.Request) async throws -> CodexReviewBackendModel.Login.Challenge
+ func startLogin(_ request: CodexReviewBackendModel.Login.Request) async throws
+ -> CodexReviewBackendModel.Login.Challenge
func cancelLogin(_ challenge: CodexReviewBackendModel.Login.Challenge) async throws
- func completeLogin(_ response: CodexReviewBackendModel.Login.Response) async throws -> CodexReviewBackendModel.Auth.Snapshot
func logout(_ account: CodexReviewBackendModel.Account.ID) async throws -> CodexReviewBackendModel.Auth.Snapshot
func startReview(_ request: CodexReviewBackendModel.Review.Start) async throws -> BackendReviewAttempt
- func interruptReview(_ run: CodexReviewBackendModel.Review.Run, reason: CodexReviewBackendModel.CancellationReason) async throws
- func beginReviewRecovery(
- _ run: CodexReviewBackendModel.Review.Run,
- reason: CodexReviewBackendModel.CancellationReason
- ) async throws -> CodexReviewBackendModel.Review.RecoveryToken
- func resumeReviewRecovery(
- _ token: CodexReviewBackendModel.Review.RecoveryToken,
+ func interruptReview(_ run: CodexReviewBackendModel.Review.Run, reason: CodexReviewBackendModel.CancellationReason)
+ async throws
+ func prepareReviewRestart(_ run: CodexReviewBackendModel.Review.Run) async throws
+ -> CodexReviewBackendModel.Review.RestartToken
+ func restartPreparedReview(
+ _ token: CodexReviewBackendModel.Review.RestartToken,
request: CodexReviewBackendModel.Review.Start
) async throws -> BackendReviewAttempt
func cleanupReview(_ run: CodexReviewBackendModel.Review.Run) async
@@ -71,7 +71,8 @@ package actor BackendReviewEventMailbox {
return
}
if let waiterID = waiters.keys.first,
- let waiter = waiters.removeValue(forKey: waiterID) {
+ let waiter = waiters.removeValue(forKey: waiterID)
+ {
waiter.resume(returning: .event(event))
} else {
bufferedEvents.append(event)
@@ -177,14 +178,7 @@ package actor BackendReviewEventMailbox {
switch event {
case .completed, .failed, .cancelled:
return true
- case .domainEvents,
- .suppressNextLegacyTimelineProjection,
- .suppressNextTerminalFailureLogTimelineProjection,
- .started,
- .message,
- .messageDelta,
- .log,
- .logEntry:
+ case .started:
return false
}
}
diff --git a/Sources/CodexReviewKit/CodexReviewTypes.swift b/Sources/CodexReviewKit/CodexReviewTypes.swift
new file mode 100644
index 00000000..82a60298
--- /dev/null
+++ b/Sources/CodexReviewKit/CodexReviewTypes.swift
@@ -0,0 +1,311 @@
+import Foundation
+
+package enum CodexReviewBackendModel {
+ package enum Settings {}
+ package enum Account {}
+ package enum Auth {}
+ package enum Login {}
+ package enum Review {}
+}
+
+package extension CodexReviewBackendModel.Settings {
+ struct Snapshot: Codable, Equatable, Sendable {
+ package var model: String?
+ package var fallbackModel: String?
+ package var reasoningEffort: String?
+ package var serviceTier: String?
+ package var models: [CodexReviewSettings.ModelCatalogItem]
+
+ package init(
+ model: String? = nil,
+ fallbackModel: String? = nil,
+ reasoningEffort: String? = nil,
+ serviceTier: String? = nil,
+ models: [CodexReviewSettings.ModelCatalogItem] = []
+ ) {
+ self.model = model
+ self.fallbackModel = fallbackModel
+ self.reasoningEffort = reasoningEffort
+ self.serviceTier = serviceTier
+ self.models = models
+ }
+ }
+}
+
+package extension CodexReviewBackendModel.Settings {
+ struct Change: Codable, Equatable, Sendable {
+ package var model: String?
+ package var reasoningEffort: String?
+ package var serviceTier: String?
+ package var updatesModel: Bool
+ package var updatesReasoningEffort: Bool
+ package var updatesServiceTier: Bool
+
+ package init(
+ model: String? = nil,
+ reasoningEffort: String? = nil,
+ serviceTier: String? = nil,
+ updatesModel: Bool? = nil,
+ updatesReasoningEffort: Bool? = nil,
+ updatesServiceTier: Bool? = nil
+ ) {
+ self.model = model
+ self.reasoningEffort = reasoningEffort
+ self.serviceTier = serviceTier
+ self.updatesModel = updatesModel ?? (model != nil)
+ self.updatesReasoningEffort = updatesReasoningEffort ?? (reasoningEffort != nil)
+ self.updatesServiceTier = updatesServiceTier ?? (serviceTier != nil)
+ }
+ }
+}
+
+package extension CodexReviewBackendModel.Account {
+ struct ID: Codable, Hashable, Sendable {
+ package var rawValue: String
+
+ package init(_ rawValue: String) {
+ self.rawValue = rawValue
+ }
+ }
+}
+
+package extension CodexReviewBackendModel.Account {
+ enum Kind: String, Codable, Equatable, Sendable {
+ case chatGPT = "chatgpt"
+ case apiKey
+ case amazonBedrock
+ }
+}
+
+package extension CodexReviewBackendModel.Account {
+ struct Capabilities: Codable, Equatable, Sendable {
+ package var supportsRateLimitRefresh: Bool
+
+ package init(supportsRateLimitRefresh: Bool = true) {
+ self.supportsRateLimitRefresh = supportsRateLimitRefresh
+ }
+
+ package static var supportsCodexRateLimits: Self {
+ .init(supportsRateLimitRefresh: true)
+ }
+
+ package static var noCodexRateLimits: Self {
+ .init(supportsRateLimitRefresh: false)
+ }
+ }
+}
+
+package extension CodexReviewBackendModel.Account.Kind {
+ var capabilities: CodexReviewBackendModel.Account.Capabilities {
+ switch self {
+ case .chatGPT:
+ .supportsCodexRateLimits
+ case .apiKey, .amazonBedrock:
+ .noCodexRateLimits
+ }
+ }
+}
+
+package extension CodexReviewBackendModel.Account {
+ struct Snapshot: Codable, Equatable, Sendable, Identifiable {
+ package var id: CodexReviewBackendModel.Account.ID
+ package var kind: CodexReviewBackendModel.Account.Kind
+ package var label: String
+ package var isActive: Bool
+ package var planType: String?
+ package var capabilities: CodexReviewBackendModel.Account.Capabilities
+
+ package init(
+ id: CodexReviewBackendModel.Account.ID,
+ kind: CodexReviewBackendModel.Account.Kind = .chatGPT,
+ label: String,
+ isActive: Bool = false,
+ planType: String? = nil,
+ capabilities: CodexReviewBackendModel.Account.Capabilities? = nil
+ ) {
+ self.id = id
+ self.kind = kind
+ self.label = label
+ self.isActive = isActive
+ self.planType = planType
+ self.capabilities = capabilities ?? kind.capabilities
+ }
+ }
+}
+
+package extension CodexReviewBackendModel.Auth {
+ struct Snapshot: Codable, Equatable, Sendable {
+ package var accounts: [CodexReviewBackendModel.Account.Snapshot]
+ package var activeAccountID: CodexReviewBackendModel.Account.ID?
+
+ package init(
+ accounts: [CodexReviewBackendModel.Account.Snapshot] = [],
+ activeAccountID: CodexReviewBackendModel.Account.ID? = nil
+ ) {
+ self.accounts = accounts
+ self.activeAccountID = activeAccountID
+ }
+ }
+}
+
+package extension CodexReviewBackendModel.Auth {
+ enum Phase: Codable, Equatable, Sendable {
+ case unknown
+ case signedOut
+ case authenticated
+ case authenticating(challengeID: String)
+ case failed(message: String)
+ }
+}
+
+package extension CodexReviewBackendModel.Login {
+ struct Request: Codable, Equatable, Sendable {
+ package var preferredAccountID: CodexReviewBackendModel.Account.ID?
+ package var nativeWebAuthenticationCallbackScheme: String?
+
+ package init(
+ preferredAccountID: CodexReviewBackendModel.Account.ID? = nil,
+ nativeWebAuthenticationCallbackScheme: String? = nil
+ ) {
+ self.preferredAccountID = preferredAccountID
+ self.nativeWebAuthenticationCallbackScheme = nativeWebAuthenticationCallbackScheme
+ }
+ }
+}
+
+package extension CodexReviewBackendModel.Login {
+ struct Challenge: Codable, Equatable, Sendable {
+ package var id: String
+ package var verificationURL: URL?
+ package var userCode: String?
+ package var nativeWebAuthenticationCallbackScheme: String?
+
+ package init(
+ id: String,
+ verificationURL: URL? = nil,
+ userCode: String? = nil,
+ nativeWebAuthenticationCallbackScheme: String? = nil
+ ) {
+ self.id = id
+ self.verificationURL = verificationURL
+ self.userCode = userCode
+ self.nativeWebAuthenticationCallbackScheme = nativeWebAuthenticationCallbackScheme
+ }
+ }
+}
+
+package extension CodexReviewBackendModel.Review {
+ struct Start: Equatable, Sendable {
+ package var runID: String
+ package var sessionID: String
+ package var request: CodexReviewAPI.Start.Request
+ package var model: String?
+
+ package init(runID: String, sessionID: String, request: CodexReviewAPI.Start.Request) {
+ self.init(runID: runID, sessionID: sessionID, request: request, model: nil)
+ }
+
+ package init(
+ runID: String,
+ sessionID: String,
+ request: CodexReviewAPI.Start.Request,
+ model: String?
+ ) {
+ self.runID = runID
+ self.sessionID = sessionID
+ self.request = request
+ self.model = model
+ }
+ }
+}
+
+package extension CodexReviewBackendModel.Review {
+ struct Run: Codable, Equatable, Sendable {
+ package var attemptID: String
+ package var threadID: String
+ package var turnID: String?
+ package var reviewThreadID: String?
+ package var model: String?
+
+ enum CodingKeys: String, CodingKey {
+ case attemptID
+ case threadID
+ case turnID
+ case reviewThreadID
+ case model
+ }
+
+ package init(
+ attemptID: String = "attempt-1",
+ threadID: String,
+ turnID: String? = nil,
+ reviewThreadID: String? = nil,
+ model: String? = nil
+ ) {
+ self.attemptID = attemptID
+ self.threadID = threadID
+ self.turnID = turnID
+ self.reviewThreadID = reviewThreadID
+ self.model = model
+ }
+
+ package init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ self.attemptID = try container.decodeIfPresent(String.self, forKey: .attemptID) ?? "attempt-1"
+ self.threadID = try container.decode(String.self, forKey: .threadID)
+ self.turnID = try container.decodeIfPresent(String.self, forKey: .turnID)
+ self.reviewThreadID = try container.decodeIfPresent(String.self, forKey: .reviewThreadID)
+ self.model = try container.decodeIfPresent(String.self, forKey: .model)
+ }
+ }
+}
+
+package extension CodexReviewBackendModel.Review {
+ struct RestartToken: Equatable, Sendable {
+ package var id: String
+ package var interruptedRun: CodexReviewBackendModel.Review.Run
+
+ package init(
+ id: String,
+ interruptedRun: CodexReviewBackendModel.Review.Run
+ ) {
+ self.id = id
+ self.interruptedRun = interruptedRun
+ }
+ }
+}
+
+package extension CodexReviewBackendModel.Review {
+ struct Completion: Equatable, Sendable {
+ package var finalReview: String?
+
+ package init(finalReview: String?) {
+ self.finalReview = finalReview?.nilIfEmpty
+ }
+ }
+}
+
+package extension CodexReviewBackendModel.Review {
+ enum Event: Equatable, Sendable {
+ case started(turnID: String, reviewThreadID: String?, model: String?)
+ case completed(CodexReviewBackendModel.Review.Completion)
+ case failed(String)
+ case cancelled(String)
+ }
+}
+
+package extension CodexReviewBackendModel.Review.Event {
+ static func completed(finalReview: String?) -> Self {
+ .completed(.init(finalReview: finalReview))
+ }
+}
+
+package extension CodexReviewBackendModel {
+ struct CancellationReason: Codable, Equatable, Sendable {
+ package var message: String
+
+ package init(message: String = "Cancellation requested.") {
+ self.message = message
+ }
+ }
+}
diff --git a/Sources/CodexReview/Model/CodexAccount.swift b/Sources/CodexReviewKit/Model/CodexReviewAccount.swift
similarity index 91%
rename from Sources/CodexReview/Model/CodexAccount.swift
rename to Sources/CodexReviewKit/Model/CodexReviewAccount.swift
index 899249fc..54e17c46 100644
--- a/Sources/CodexReview/Model/CodexAccount.swift
+++ b/Sources/CodexReviewKit/Model/CodexReviewAccount.swift
@@ -3,7 +3,7 @@ import Observation
@MainActor
@Observable
-public final class CodexAccount: Identifiable, Hashable {
+public final class CodexReviewAccount: Identifiable, Hashable {
@MainActor
@Observable
public final class RateLimitWindow: Identifiable, Hashable {
@@ -19,7 +19,7 @@ public final class CodexAccount: Identifiable, Hashable {
usedPercent: Int,
resetsAt: Date? = nil
) {
- precondition(windowDurationMinutes > 0, "CodexAccount.RateLimitWindow duration must be positive.")
+ precondition(windowDurationMinutes > 0, "CodexReviewAccount.RateLimitWindow duration must be positive.")
self.accountKey = accountKey
self.windowDurationMinutes = windowDurationMinutes
self.id = "\(accountKey):\(windowDurationMinutes)"
@@ -97,12 +97,12 @@ public final class CodexAccount: Identifiable, Hashable {
capabilities: CodexReviewBackendModel.Account.Capabilities? = nil
) {
let trimmedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
- precondition(trimmedEmail.isEmpty == false, "CodexAccount email must not be empty.")
- let normalizedEmail = CodexAccount.normalizedEmail(trimmedEmail)
+ precondition(trimmedEmail.isEmpty == false, "CodexReviewAccount email must not be empty.")
+ let normalizedEmail = CodexReviewAccount.normalizedEmail(trimmedEmail)
let resolvedAccountKey = accountKey.map {
- CodexAccount.normalizedEmail($0)
+ CodexReviewAccount.normalizedEmail($0)
} ?? normalizedEmail
- precondition(resolvedAccountKey.isEmpty == false, "CodexAccount accountKey must not be empty.")
+ precondition(resolvedAccountKey.isEmpty == false, "CodexReviewAccount accountKey must not be empty.")
self.id = resolvedAccountKey
self.email = trimmedEmail
self.maskedEmail = maskedReviewAccountEmail(trimmedEmail)
@@ -124,7 +124,7 @@ public final class CodexAccount: Identifiable, Hashable {
package func updateEmail(_ email: String) {
let trimmedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
- precondition(trimmedEmail.isEmpty == false, "CodexAccount email must not be empty.")
+ precondition(trimmedEmail.isEmpty == false, "CodexReviewAccount email must not be empty.")
self.email = trimmedEmail
self.maskedEmail = maskedReviewAccountEmail(trimmedEmail)
}
@@ -169,7 +169,7 @@ public final class CodexAccount: Identifiable, Hashable {
}
result[rateLimit.windowDurationMinutes] = rateLimit
}
- let existingRateLimitsByDuration = self.rateLimits.reduce(into: [Int: CodexAccount.RateLimitWindow]()) { result, window in
+ let existingRateLimitsByDuration = self.rateLimits.reduce(into: [Int: CodexReviewAccount.RateLimitWindow]()) { result, window in
result[window.windowDurationMinutes] = window
}
@@ -184,7 +184,7 @@ public final class CodexAccount: Identifiable, Hashable {
return existingRateLimit
}
- return CodexAccount.RateLimitWindow(
+ return CodexReviewAccount.RateLimitWindow(
accountKey: accountKey,
windowDurationMinutes: rateLimit.windowDurationMinutes,
usedPercent: rateLimit.usedPercent,
@@ -243,7 +243,7 @@ public final class CodexAccount: Identifiable, Hashable {
}
}
-private extension CodexAccount {
+private extension CodexReviewAccount {
static func reauthenticationRequiredMessage(from error: String) -> String {
let trimmedError = error.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmedError.isEmpty == false else {
@@ -285,8 +285,8 @@ package struct CodexSavedAccountPayload: Sendable {
}
@MainActor
-package func makeCodexAccount(from payload: CodexSavedAccountPayload) -> CodexAccount {
- let account = CodexAccount(
+package func makeCodexReviewAccount(from payload: CodexSavedAccountPayload) -> CodexReviewAccount {
+ let account = CodexReviewAccount(
accountKey: payload.accountKey,
email: payload.email,
planType: payload.planType,
@@ -298,7 +298,7 @@ package func makeCodexAccount(from payload: CodexSavedAccountPayload) -> CodexAc
}
@MainActor
-package func savedAccountPayload(from account: CodexAccount) -> CodexSavedAccountPayload {
+package func savedAccountPayload(from account: CodexReviewAccount) -> CodexSavedAccountPayload {
.init(
accountKey: account.accountKey,
email: account.email,
@@ -342,8 +342,8 @@ private func maskedReviewAccountEmailSegment(_ segment: String) -> String {
}
}
-extension CodexAccount {
- public static nonisolated func == (lhs: CodexAccount, rhs: CodexAccount) -> Bool {
+extension CodexReviewAccount {
+ public static nonisolated func == (lhs: CodexReviewAccount, rhs: CodexReviewAccount) -> Bool {
lhs.id == rhs.id
}
diff --git a/Sources/CodexReview/Model/CodexReviewAuthModel.swift b/Sources/CodexReviewKit/Model/CodexReviewAuthModel.swift
similarity index 93%
rename from Sources/CodexReview/Model/CodexReviewAuthModel.swift
rename to Sources/CodexReviewKit/Model/CodexReviewAuthModel.swift
index 4274b4ec..506f73fd 100644
--- a/Sources/CodexReview/Model/CodexReviewAuthModel.swift
+++ b/Sources/CodexReviewKit/Model/CodexReviewAuthModel.swift
@@ -21,7 +21,7 @@ public final class CodexReviewAuthModel {
}
package var confirmationMessage: LocalizedStringResource {
- "Running review jobs may stop after the account change is applied."
+ "Running review runs may stop after the account change is applied."
}
package var confirmationButtonTitle: LocalizedStringResource {
@@ -78,10 +78,10 @@ public final class CodexReviewAuthModel {
}
public package(set) var phase: Phase = .signedOut
- public package(set) var persistedAccounts: [CodexAccount] = []
+ public package(set) var persistedAccounts: [CodexReviewAccount] = []
public package(set) var persistedActiveAccountKey: String?
- package private(set) var detachedAccount: CodexAccount?
- public private(set) var selectedAccount: CodexAccount?
+ package private(set) var detachedAccount: CodexReviewAccount?
+ public private(set) var selectedAccount: CodexReviewAccount?
public package(set) var authenticationFailureCount = 0
public package(set) var warningMessage: String?
@@ -103,7 +103,7 @@ public final class CodexReviewAuthModel {
selectedAccount != nil
}
- public var accounts: [CodexAccount] {
+ public var accounts: [CodexReviewAccount] {
guard let detachedAccount else {
return persistedAccounts
}
@@ -127,13 +127,13 @@ public final class CodexReviewAuthModel {
package init() {}
- package func canRequestSwitchAccount(_ account: CodexAccount) -> Bool {
+ package func canRequestSwitchAccount(_ account: CodexReviewAccount) -> Bool {
persistedAccounts.contains { $0.accountKey == account.accountKey }
&& selectedAccount?.accountKey != account.accountKey
}
package func requestSwitchAccount(
- _ account: CodexAccount,
+ _ account: CodexReviewAccount,
requiresConfirmation: Bool
) {
guard canRequestSwitchAccount(account) else {
@@ -156,7 +156,7 @@ public final class CodexReviewAuthModel {
}
package func requestRemoveAccount(
- _ account: CodexAccount,
+ _ account: CodexReviewAccount,
requiresConfirmation: Bool
) {
requestAccountAction(
@@ -205,7 +205,7 @@ public final class CodexReviewAuthModel {
warningMessage = message?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
}
- package func selectPersistedAccount(_ persistedAccountID: CodexAccount.ID?) {
+ package func selectPersistedAccount(_ persistedAccountID: CodexReviewAccount.ID?) {
guard let persistedAccountID else {
selectedAccount = nil
detachedAccount = nil
@@ -215,7 +215,7 @@ public final class CodexReviewAuthModel {
detachedAccount = nil
}
- package func updateCurrentAccount(_ account: CodexAccount?) {
+ package func updateCurrentAccount(_ account: CodexReviewAccount?) {
guard let account else {
selectedAccount = nil
detachedAccount = nil
@@ -247,7 +247,7 @@ public final class CodexReviewAuthModel {
) {
let resolvedPersistedAccounts = incomingPersistedAccounts.map { incomingAccount in
let reconciledAccount = reusableAccount(for: incomingAccount.accountKey)
- ?? CodexAccount(
+ ?? CodexReviewAccount(
accountKey: incomingAccount.accountKey,
email: incomingAccount.email,
planType: incomingAccount.planType
@@ -268,7 +268,7 @@ public final class CodexReviewAuthModel {
persistedActiveAccountKey == accountKey
}
- private func reusableAccount(for accountKey: String) -> CodexAccount? {
+ private func reusableAccount(for accountKey: String) -> CodexReviewAccount? {
if let persistedAccount = persistedAccounts.first(where: { $0.accountKey == accountKey }) {
return persistedAccount
}
diff --git a/Sources/CodexReview/Model/CodexReviewServerState.swift b/Sources/CodexReviewKit/Model/CodexReviewServerState.swift
similarity index 100%
rename from Sources/CodexReview/Model/CodexReviewServerState.swift
rename to Sources/CodexReviewKit/Model/CodexReviewServerState.swift
diff --git a/Sources/CodexReview/Model/CodexReviewSettings.swift b/Sources/CodexReviewKit/Model/CodexReviewSettings.swift
similarity index 100%
rename from Sources/CodexReview/Model/CodexReviewSettings.swift
rename to Sources/CodexReviewKit/Model/CodexReviewSettings.swift
diff --git a/Sources/CodexReview/Model/ParsedReviewResult.swift b/Sources/CodexReviewKit/Model/ParsedReviewResult.swift
similarity index 100%
rename from Sources/CodexReview/Model/ParsedReviewResult.swift
rename to Sources/CodexReviewKit/Model/ParsedReviewResult.swift
diff --git a/Sources/CodexReview/Model/ReviewCancellation.swift b/Sources/CodexReviewKit/Model/ReviewCancellation.swift
similarity index 68%
rename from Sources/CodexReview/Model/ReviewCancellation.swift
rename to Sources/CodexReviewKit/Model/ReviewCancellation.swift
index 1321f67d..19944fa6 100644
--- a/Sources/CodexReview/Model/ReviewCancellation.swift
+++ b/Sources/CodexReviewKit/Model/ReviewCancellation.swift
@@ -1,38 +1,38 @@
-public struct ReviewCancellation: Codable, Sendable, Hashable {
- public enum Source: String, Codable, Sendable, Hashable {
+package struct ReviewCancellation: Codable, Sendable, Hashable {
+ package enum Source: String, Codable, Sendable, Hashable {
case userInterface
case mcpClient
case sessionClosed
case system
}
- public var source: Source
- public var message: String
+ package var source: Source
+ package var message: String
- public init(source: Source, message: String) {
+ package init(source: Source, message: String) {
self.source = source
self.message = message
}
- public static func userInterface(
+ package static func userInterface(
message: String = "Cancelled by user from Review Monitor."
) -> ReviewCancellation {
ReviewCancellation(source: .userInterface, message: message)
}
- public static func mcpClient(
+ package static func mcpClient(
message: String = "Cancellation requested by MCP client."
) -> ReviewCancellation {
ReviewCancellation(source: .mcpClient, message: message)
}
- public static func sessionClosed(
+ package static func sessionClosed(
message: String = "Cancellation requested because the MCP session closed."
) -> ReviewCancellation {
ReviewCancellation(source: .sessionClosed, message: message)
}
- public static func system(
+ package static func system(
message: String = "Cancellation requested."
) -> ReviewCancellation {
ReviewCancellation(source: .system, message: message)
diff --git a/Sources/CodexReviewKit/Model/ReviewRunCore.swift b/Sources/CodexReviewKit/Model/ReviewRunCore.swift
new file mode 100644
index 00000000..3897a280
--- /dev/null
+++ b/Sources/CodexReviewKit/Model/ReviewRunCore.swift
@@ -0,0 +1,71 @@
+import Foundation
+
+package struct ReviewRunCore: Codable, Sendable, Hashable {
+ package struct Run: Codable, Sendable, Hashable {
+ package var attemptID: String?
+ package var reviewThreadID: String?
+ package var threadID: String?
+ package var turnID: String?
+ package var model: String?
+
+ package init(
+ attemptID: String? = nil,
+ reviewThreadID: String? = nil,
+ threadID: String? = nil,
+ turnID: String? = nil,
+ model: String? = nil
+ ) {
+ self.attemptID = attemptID
+ self.reviewThreadID = reviewThreadID
+ self.threadID = threadID
+ self.turnID = turnID
+ self.model = model
+ }
+ }
+
+ package struct Lifecycle: Codable, Sendable, Hashable {
+ package var status: ReviewRunState
+ package var exitCode: Int?
+ package var startedAt: Date?
+ package var endedAt: Date?
+ package var cancellation: ReviewCancellation?
+ package var errorMessage: String?
+
+ package init(
+ status: ReviewRunState,
+ exitCode: Int? = nil,
+ startedAt: Date? = nil,
+ endedAt: Date? = nil,
+ cancellation: ReviewCancellation? = nil,
+ errorMessage: String? = nil
+ ) {
+ self.status = status
+ self.exitCode = exitCode
+ self.startedAt = startedAt
+ self.endedAt = endedAt
+ self.cancellation = cancellation
+ self.errorMessage = errorMessage
+ }
+ }
+
+ package var run: Run
+ package var lifecycle: Lifecycle
+ package var lifecycleMessage: String
+ package var finalReview: String?
+
+ package init(
+ run: Run = .init(),
+ lifecycle: Lifecycle,
+ lifecycleMessage: String,
+ finalReview: String? = nil
+ ) {
+ self.run = run
+ self.lifecycle = lifecycle
+ self.lifecycleMessage = lifecycleMessage
+ self.finalReview = finalReview?.nilIfEmpty
+ }
+
+ package var isTerminal: Bool {
+ lifecycle.status.isTerminal
+ }
+}
diff --git a/Sources/CodexReviewKit/Model/ReviewRunRecord.swift b/Sources/CodexReviewKit/Model/ReviewRunRecord.swift
new file mode 100644
index 00000000..acb522de
--- /dev/null
+++ b/Sources/CodexReviewKit/Model/ReviewRunRecord.swift
@@ -0,0 +1,45 @@
+import Foundation
+import Observation
+
+@MainActor
+@Observable
+package final class ReviewRunRecord: Identifiable, Hashable {
+ package nonisolated let id: String
+ package let sessionID: String
+ package let cwd: String
+ package var sortOrder: Double
+ package var targetSummary: String
+ package var core: ReviewRunCore
+ package var cancellationRequested: Bool
+
+ package var isTerminal: Bool {
+ core.isTerminal
+ }
+
+ package init(
+ id: String,
+ sessionID: String,
+ cwd: String,
+ sortOrder: Double = 0,
+ targetSummary: String,
+ core: ReviewRunCore,
+ cancellationRequested: Bool = false
+ ) {
+ self.id = id
+ self.sessionID = sessionID
+ self.cwd = cwd
+ self.sortOrder = sortOrder
+ self.targetSummary = targetSummary
+ self.core = core
+ self.cancellationRequested = cancellationRequested
+ }
+
+ package nonisolated static func == (lhs: ReviewRunRecord, rhs: ReviewRunRecord) -> Bool {
+ lhs.id == rhs.id
+ }
+
+ package nonisolated func hash(into hasher: inout Hasher) {
+ hasher.combine(id)
+ }
+
+}
diff --git a/Sources/CodexReview/Model/CodexReviewJobTesting.swift b/Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift
similarity index 66%
rename from Sources/CodexReview/Model/CodexReviewJobTesting.swift
rename to Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift
index e3cd508a..49c8f5ea 100644
--- a/Sources/CodexReview/Model/CodexReviewJobTesting.swift
+++ b/Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift
@@ -1,8 +1,7 @@
import Foundation
-extension CodexReviewJob {
- @_spi(Testing)
- public static func makeForTesting(
+extension ReviewRunRecord {
+ package static func makeForTesting(
id: String = UUID().uuidString,
sessionID: String = "session-1",
cwd: String = "/tmp/repo",
@@ -10,25 +9,21 @@ extension CodexReviewJob {
model: String? = "gpt-5",
threadID: String? = nil,
turnID: String? = nil,
- status: ReviewJobState,
+ status: ReviewRunState,
cancellationRequested: Bool = false,
cancellation: ReviewCancellation? = nil,
startedAt: Date? = nil,
endedAt: Date? = nil,
summary: String,
- hasFinalReview: Bool = false,
- reviewResult: ParsedReviewResult? = nil,
- lastAgentMessage: String? = "",
- logEntries: [ReviewLogEntry] = [],
errorMessage: String? = nil,
exitCode: Int? = nil
- ) -> CodexReviewJob {
- CodexReviewJob(
+ ) -> ReviewRunRecord {
+ ReviewRunRecord(
id: id,
sessionID: sessionID,
cwd: cwd,
targetSummary: targetSummary,
- core: ReviewJobCore(
+ core: ReviewRunCore(
run: .init(
reviewThreadID: threadID,
threadID: threadID,
@@ -43,22 +38,15 @@ extension CodexReviewJob {
cancellation: cancellation,
errorMessage: errorMessage
),
- output: .init(
- summary: summary,
- hasFinalReview: hasFinalReview,
- lastAgentMessage: lastAgentMessage,
- reviewResult: reviewResult
- )
+ lifecycleMessage: summary
),
- cancellationRequested: cancellationRequested,
- logEntries: logEntries
+ cancellationRequested: cancellationRequested
)
}
- @_spi(Testing)
- public func updateStateForTesting(
+ package func updateStateForTesting(
targetSummary: String? = nil,
- status: ReviewJobState? = nil,
+ status: ReviewRunState? = nil,
endedAt: Date? = nil,
clearEndedAt: Bool = false,
summary: String? = nil
@@ -75,7 +63,7 @@ extension CodexReviewJob {
core.lifecycle.endedAt = nil
}
if let summary {
- core.output.summary = summary
+ core.lifecycleMessage = summary
}
}
}
diff --git a/Sources/CodexReview/Model/ReviewJobState.swift b/Sources/CodexReviewKit/Model/ReviewRunState.swift
similarity index 78%
rename from Sources/CodexReview/Model/ReviewJobState.swift
rename to Sources/CodexReviewKit/Model/ReviewRunState.swift
index d0bf4d07..815cdb48 100644
--- a/Sources/CodexReview/Model/ReviewJobState.swift
+++ b/Sources/CodexReviewKit/Model/ReviewRunState.swift
@@ -1,11 +1,11 @@
-public enum ReviewJobState: String, Codable, Sendable, Hashable {
+package enum ReviewRunState: String, Codable, Sendable, Hashable {
case queued
case running
case succeeded
case failed
case cancelled
- public var isTerminal: Bool {
+ package var isTerminal: Bool {
switch self {
case .queued, .running:
false
@@ -14,7 +14,7 @@ public enum ReviewJobState: String, Codable, Sendable, Hashable {
}
}
- public var displayText: String {
+ package var displayText: String {
switch self {
case .queued:
"Queued"
diff --git a/Sources/CodexReview/Model/SettingsStore.swift b/Sources/CodexReviewKit/Model/SettingsStore.swift
similarity index 100%
rename from Sources/CodexReview/Model/SettingsStore.swift
rename to Sources/CodexReviewKit/Model/SettingsStore.swift
diff --git a/Sources/CodexReview/Network/CodexReviewNetworkMonitoring.swift b/Sources/CodexReviewKit/Network/CodexReviewNetworkMonitoring.swift
similarity index 100%
rename from Sources/CodexReview/Network/CodexReviewNetworkMonitoring.swift
rename to Sources/CodexReviewKit/Network/CodexReviewNetworkMonitoring.swift
diff --git a/Sources/CodexReview/ReviewAPI/CodexReviewAPI.swift b/Sources/CodexReviewKit/ReviewAPI/CodexReviewAPI.swift
similarity index 73%
rename from Sources/CodexReview/ReviewAPI/CodexReviewAPI.swift
rename to Sources/CodexReviewKit/ReviewAPI/CodexReviewAPI.swift
index 6a9e5c0e..084db9aa 100644
--- a/Sources/CodexReview/ReviewAPI/CodexReviewAPI.swift
+++ b/Sources/CodexReviewKit/ReviewAPI/CodexReviewAPI.swift
@@ -1,8 +1,7 @@
package enum CodexReviewAPI {
package enum Start {}
package enum Read {}
- package enum Log {}
- package enum Job {}
+ package enum Run {}
package enum List {}
package enum Cancel {}
}
diff --git a/Sources/CodexReview/ReviewAPI/ReviewError.swift b/Sources/CodexReviewKit/ReviewAPI/ReviewError.swift
similarity index 89%
rename from Sources/CodexReview/ReviewAPI/ReviewError.swift
rename to Sources/CodexReviewKit/ReviewAPI/ReviewError.swift
index 9855e4b5..45c3c306 100644
--- a/Sources/CodexReview/ReviewAPI/ReviewError.swift
+++ b/Sources/CodexReviewKit/ReviewAPI/ReviewError.swift
@@ -3,7 +3,7 @@ import Foundation
package extension CodexReviewAPI {
enum Error: Swift.Error, LocalizedError, Sendable {
case invalidArguments(String)
- case jobNotFound(String)
+ case runNotFound(String)
case accessDenied(String)
case spawnFailed(String)
case bootstrapFailed(String)
@@ -12,7 +12,7 @@ package extension CodexReviewAPI {
package var errorDescription: String? {
switch self {
case .invalidArguments(let message),
- .jobNotFound(let message),
+ .runNotFound(let message),
.accessDenied(let message),
.spawnFailed(let message),
.bootstrapFailed(let message),
diff --git a/Sources/CodexReviewKit/ReviewAPI/ReviewResults.swift b/Sources/CodexReviewKit/ReviewAPI/ReviewResults.swift
new file mode 100644
index 00000000..dc3cd9be
--- /dev/null
+++ b/Sources/CodexReviewKit/ReviewAPI/ReviewResults.swift
@@ -0,0 +1,123 @@
+import Foundation
+
+package extension CodexReviewAPI.Read {
+struct Result: Codable, Sendable, Hashable {
+ package var runID: String
+ package var core: ReviewRunCore
+ package var elapsedSeconds: Int?
+ package var cancellable: Bool
+
+ package init(
+ runID: String,
+ core: ReviewRunCore,
+ elapsedSeconds: Int? = nil,
+ cancellable: Bool
+ ) {
+ self.runID = runID
+ self.core = core
+ self.elapsedSeconds = elapsedSeconds
+ self.cancellable = cancellable
+ }
+}
+}
+
+
+package extension CodexReviewAPI.Run {
+struct ListItem: Codable, Sendable, Hashable {
+ package var runID: String
+ package var cwd: String
+ package var targetSummary: String
+ package var core: ReviewRunCore
+ package var elapsedSeconds: Int?
+ package var cancellable: Bool
+
+ package init(
+ runID: String,
+ cwd: String,
+ targetSummary: String,
+ core: ReviewRunCore,
+ elapsedSeconds: Int?,
+ cancellable: Bool
+ ) {
+ self.runID = runID
+ self.cwd = cwd
+ self.targetSummary = targetSummary
+ self.core = core
+ self.elapsedSeconds = elapsedSeconds
+ self.cancellable = cancellable
+ }
+}
+}
+
+
+package extension CodexReviewAPI.List {
+struct Result: Codable, Sendable, Hashable {
+ package var items: [CodexReviewAPI.Run.ListItem]
+
+ package init(items: [CodexReviewAPI.Run.ListItem]) {
+ self.items = items
+ }
+}
+}
+
+
+package extension CodexReviewAPI.Run {
+struct Selector: Sendable, Hashable {
+ package var runID: String?
+ package var cwd: String?
+ package var statuses: [ReviewRunState]?
+
+ package init(
+ runID: String? = nil,
+ cwd: String? = nil,
+ statuses: [ReviewRunState]? = nil
+ ) {
+ self.runID = runID
+ self.cwd = cwd?.nilIfEmpty
+ self.statuses = statuses
+ }
+}
+}
+
+
+package extension CodexReviewAPI.Run {
+enum SelectionError: Swift.Error, Sendable {
+ case ambiguous([CodexReviewAPI.Run.ListItem])
+}
+}
+
+
+extension CodexReviewAPI.Run.SelectionError: LocalizedError {
+ package var errorDescription: String? {
+ switch self {
+ case .ambiguous(let reviewRuns):
+ let candidates = reviewRuns
+ .map { "- \($0.runID) [\($0.core.lifecycle.status.rawValue)] \($0.cwd) \($0.targetSummary)" }
+ .joined(separator: "\n")
+ return """
+ Review run selector matched multiple review runs:
+ \(candidates)
+ Specify runID or narrow cwd/statuses.
+ """
+ }
+ }
+}
+
+
+package extension CodexReviewAPI.Cancel {
+struct Outcome: Codable, Sendable, Hashable {
+ package var runID: String
+ package var cancelled: Bool
+ package var core: ReviewRunCore
+
+ package init(
+ runID: String,
+ cancelled: Bool,
+ core: ReviewRunCore
+ ) {
+ self.runID = runID
+ self.cancelled = cancelled
+ self.core = core
+ }
+}
+}
diff --git a/Sources/CodexReview/ReviewAPI/ReviewStartRequest.swift b/Sources/CodexReviewKit/ReviewAPI/ReviewStartRequest.swift
similarity index 100%
rename from Sources/CodexReview/ReviewAPI/ReviewStartRequest.swift
rename to Sources/CodexReviewKit/ReviewAPI/ReviewStartRequest.swift
diff --git a/Sources/CodexReview/ReviewAPI/ReviewTarget.swift b/Sources/CodexReviewKit/ReviewAPI/ReviewTarget.swift
similarity index 100%
rename from Sources/CodexReview/ReviewAPI/ReviewTarget.swift
rename to Sources/CodexReviewKit/ReviewAPI/ReviewTarget.swift
diff --git a/Sources/CodexReview/Settings/CodexReviewSettingsService.swift b/Sources/CodexReviewKit/Settings/CodexReviewSettingsService.swift
similarity index 100%
rename from Sources/CodexReview/Settings/CodexReviewSettingsService.swift
rename to Sources/CodexReviewKit/Settings/CodexReviewSettingsService.swift
diff --git a/Sources/CodexReview/Store/CodexReviewStore.swift b/Sources/CodexReviewKit/Store/CodexReviewStore.swift
similarity index 82%
rename from Sources/CodexReview/Store/CodexReviewStore.swift
rename to Sources/CodexReviewKit/Store/CodexReviewStore.swift
index f60abe0c..b2c5d158 100644
--- a/Sources/CodexReview/Store/CodexReviewStore.swift
+++ b/Sources/CodexReviewKit/Store/CodexReviewStore.swift
@@ -8,8 +8,7 @@ public final class CodexReviewStore {
public let auth: CodexReviewAuthModel
package let settings: SettingsStore
public package(set) var serverURL: URL?
- public package(set) var workspaces: Set = []
- public package(set) var jobs: Set = []
+ package var reviewRuns: Set = []
package var shouldAutoStartEmbeddedServer: Bool {
backend.seed.shouldAutoStartEmbeddedServer
}
@@ -19,15 +18,9 @@ public final class CodexReviewStore {
@ObservationIgnored package let backend: any CodexReviewStoreBackend
@ObservationIgnored package let networkMonitor: any CodexReviewNetworkMonitoring
@ObservationIgnored package let networkRecoveryPolicy: CodexReviewNetworkRecoveryPolicy
- @ObservationIgnored package var previewSupportRetainer: AnyObject?
@ObservationIgnored package let clock: CodexReviewClock
@ObservationIgnored package let idGenerator: CodexReviewIDGenerator
- @ObservationIgnored package var activeRuns: [String: CodexReviewBackendModel.Review.Run] = [:]
- @ObservationIgnored package var reviewRecoveryWaitingJobIDs: Set = []
- @ObservationIgnored package var startingJobIDs: Set = []
- @ObservationIgnored package var startupCancellations: [String: ReviewCancellation] = [:]
- @ObservationIgnored package var reviewWorkerTasks: [String: Task] = [:]
- @ObservationIgnored package var runtimeStopDetachedReviewWorkerTasks: [String: Task] = [:]
+ @ObservationIgnored let runtimeState = CodexReviewStoreRuntimeState()
@ObservationIgnored package var closedSessions: Set = []
@ObservationIgnored package var accountRateLimitAutoRefreshDriver: CodexReviewStoreRateLimitAutoRefreshDriver?
@@ -71,12 +64,7 @@ public final class CodexReviewStore {
isolated deinit {
accountRateLimitAutoRefreshDriver?.cancel()
- for task in reviewWorkerTasks.values {
- task.cancel()
- }
- for task in runtimeStopDetachedReviewWorkerTasks.values {
- task.cancel()
- }
+ runtimeState.cancelAllWorkers()
}
public static func makePreviewStore(diagnosticsURL: URL? = nil) -> CodexReviewStore {
@@ -132,16 +120,16 @@ public final class CodexReviewStore {
}
public func stop() async {
- let locallyCancelledJobIDs: [String]
- if backend.handlesActiveReviewStopCleanup {
- locallyCancelledJobIDs = []
+ let locallyCancelledReviewRunIDs: [String]
+ if backend.invokesRuntimeStopReviewCleanupDuringStop {
+ locallyCancelledReviewRunIDs = []
} else {
- locallyCancelledJobIDs = await requestActiveReviewCancellationsForRuntimeStop()
+ locallyCancelledReviewRunIDs = await requestActiveReviewCancellationsForRuntimeStop()
}
await backend.stop(store: self)
- let remainingLocallyCancelledJobIDs = cancelActiveReviewsLocallyForRuntimeStop(cancelWorkers: false)
+ let remainingLocallyCancelledReviewRunIDs = cancelActiveReviewsLocallyForRuntimeStop(cancelWorkers: false)
cancelAndDetachReviewWorkersForRuntimeStop(
- jobIDs: Array(Set(locallyCancelledJobIDs + remainingLocallyCancelledJobIDs))
+ runIDs: Array(Set(locallyCancelledReviewRunIDs + remainingLocallyCancelledReviewRunIDs))
)
transitionToStopped()
}
@@ -208,7 +196,7 @@ public final class CodexReviewStore {
try await backend.signOutActiveAccount(auth: auth)
}
- package func switchAccount(_ account: CodexAccount) async throws {
+ package func switchAccount(_ account: CodexReviewAccount) async throws {
guard canSwitchAccount(account) else {
return
}
@@ -223,7 +211,7 @@ public final class CodexReviewStore {
try await backend.switchAccount(auth: auth, accountKey: account.accountKey)
}
- package func requestSwitchAccount(_ account: CodexAccount, requiresConfirmation: Bool) {
+ package func requestSwitchAccount(_ account: CodexReviewAccount, requiresConfirmation: Bool) {
auth.requestSwitchAccount(account, requiresConfirmation: requiresConfirmation)
guard requiresConfirmation == false else {
return
@@ -231,11 +219,11 @@ public final class CodexReviewStore {
confirmPendingAccountAction()
}
- package func requestSwitchAccountFromUserAction(_ account: CodexAccount) {
+ package func requestSwitchAccountFromUserAction(_ account: CodexReviewAccount) {
requestSwitchAccount(
account,
- requiresConfirmation: hasRunningJobs
- && switchActionRequiresRunningJobsConfirmation(for: account)
+ requiresConfirmation: hasRunningReviewRuns
+ && switchActionRequiresRunningReviewRunsConfirmation(for: account)
)
}
@@ -247,7 +235,7 @@ public final class CodexReviewStore {
confirmPendingAccountAction()
}
- package func requestRemoveAccount(_ account: CodexAccount, requiresConfirmation: Bool) {
+ package func requestRemoveAccount(_ account: CodexReviewAccount, requiresConfirmation: Bool) {
auth.requestRemoveAccount(account, requiresConfirmation: requiresConfirmation)
guard requiresConfirmation == false else {
return
@@ -310,11 +298,11 @@ public final class CodexReviewStore {
package func reconcileAuthenticatedSession(serverIsRunning _: Bool, runtimeGeneration _: Int) async {}
- package func switchActionIsDisabled(for account: CodexAccount) -> Bool {
+ package func switchActionIsDisabled(for account: CodexReviewAccount) -> Bool {
canSwitchAccount(account) == false
}
- package func switchActionRequiresRunningJobsConfirmation(for account: CodexAccount) -> Bool {
+ package func switchActionRequiresRunningReviewRunsConfirmation(for account: CodexReviewAccount) -> Bool {
guard canSwitchAccount(account) else {
return false
}
@@ -351,18 +339,18 @@ public final class CodexReviewStore {
writeDiagnosticsIfNeeded()
}
- package func transitionToFailed(_ message: String, resetJobs: Bool = false) {
+ package func transitionToFailed(_ message: String, resetReviewRuns: Bool = false) {
serverURL = nil
- if resetJobs {
+ if resetReviewRuns {
resetReviews()
}
serverState = .failed(message)
writeDiagnosticsIfNeeded()
}
- package func transitionToStopped(resetJobs: Bool = false) {
+ package func transitionToStopped(resetReviewRuns: Bool = false) {
serverURL = nil
- if resetJobs {
+ if resetReviewRuns {
resetReviews()
}
serverState = .stopped
@@ -373,12 +361,10 @@ public final class CodexReviewStore {
guard let diagnosticsURL else {
return
}
- let jobs = orderedJobs.map { job in
- CodexReviewStoreDiagnosticsSnapshot.Job(
- status: job.core.lifecycle.status.rawValue,
- summary: job.core.output.summary,
- logText: job.logText,
- rawLogText: job.rawLogText
+ let reviewRuns: [CodexReviewStoreDiagnosticsSnapshot.Run] = orderedReviewRuns.map { runRecord in
+ return CodexReviewStoreDiagnosticsSnapshot.Run(
+ status: runRecord.core.lifecycle.status.rawValue,
+ lifecycleMessage: runRecord.core.lifecycleMessage
)
}
let snapshot = CodexReviewStoreDiagnosticsSnapshot(
@@ -386,7 +372,7 @@ public final class CodexReviewStore {
failureMessage: serverState.failureMessage,
serverURL: serverURL?.absoluteString,
childRuntimePath: nil,
- jobs: jobs
+ reviewRuns: reviewRuns
)
do {
try FileManager.default.createDirectory(
@@ -396,20 +382,20 @@ public final class CodexReviewStore {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(snapshot)
- try data.write(to: diagnosticsURL, options: .atomic)
+ try data.write(to: diagnosticsURL, options: Data.WritingOptions.atomic)
} catch {}
}
- package func noteJobMutation() {
+ package func noteReviewRunMutation() {
writeDiagnosticsIfNeeded()
}
- public var hasRunningJobs: Bool {
- jobs.contains(where: { $0.isTerminal == false })
+ public var hasRunningReviewRuns: Bool {
+ reviewRuns.contains(where: { $0.isTerminal == false })
}
- public var runningJobCount: Int {
- jobs.filter { $0.isTerminal == false }.count
+ public var runningReviewRunCount: Int {
+ reviewRuns.filter { $0.isTerminal == false }.count
}
public var canPerformPrimaryAuthenticationAction: Bool {
@@ -428,8 +414,7 @@ public final class CodexReviewStore {
}
private func resetReviews() {
- workspaces = []
- jobs = []
+ reviewRuns = []
}
private func executePendingAccountAction(_ action: CodexReviewAuthModel.PendingAccountAction) async throws {
@@ -446,7 +431,7 @@ public final class CodexReviewStore {
}
}
- private func canSwitchAccount(_ account: CodexAccount) -> Bool {
+ private func canSwitchAccount(_ account: CodexReviewAccount) -> Bool {
auth.canRequestSwitchAccount(account)
}
diff --git a/Sources/CodexReview/Store/CodexReviewStoreBackend.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreBackend.swift
similarity index 60%
rename from Sources/CodexReview/Store/CodexReviewStoreBackend.swift
rename to Sources/CodexReviewKit/Store/CodexReviewStoreBackend.swift
index ad4d13d4..3358a336 100644
--- a/Sources/CodexReview/Store/CodexReviewStoreBackend.swift
+++ b/Sources/CodexReviewKit/Store/CodexReviewStoreBackend.swift
@@ -3,15 +3,15 @@ import Foundation
@MainActor
package struct CodexReviewStoreSeed {
package var shouldAutoStartEmbeddedServer: Bool
- package var initialAccount: CodexAccount?
- package var initialAccounts: [CodexAccount]
+ package var initialAccount: CodexReviewAccount?
+ package var initialAccounts: [CodexReviewAccount]
package var initialActiveAccountKey: String?
package var initialSettingsSnapshot: CodexReviewSettings.Snapshot
package init(
shouldAutoStartEmbeddedServer: Bool = false,
- initialAccount: CodexAccount? = nil,
- initialAccounts: [CodexAccount] = [],
+ initialAccount: CodexReviewAccount? = nil,
+ initialAccounts: [CodexReviewAccount] = [],
initialActiveAccountKey: String? = nil,
initialSettingsSnapshot: CodexReviewSettings.Snapshot = .init()
) {
@@ -27,7 +27,7 @@ package struct CodexReviewStoreSeed {
package protocol CodexReviewStoreBackend: CodexReviewSettingsBackend {
var seed: CodexReviewStoreSeed { get }
var isActive: Bool { get }
- var handlesActiveReviewStopCleanup: Bool { get }
+ var invokesRuntimeStopReviewCleanupDuringStop: Bool { get }
func attachStore(_ store: CodexReviewStore)
func start(store: CodexReviewStore, forceRestartIfNeeded: Bool) async
@@ -46,19 +46,46 @@ package protocol CodexReviewStoreBackend: CodexReviewSettingsBackend {
func startReview(_ request: CodexReviewBackendModel.Review.Start) async throws -> BackendReviewAttempt
func interruptReview(_ run: CodexReviewBackendModel.Review.Run, reason: CodexReviewBackendModel.CancellationReason) async throws
- func beginReviewRecovery(
- _ run: CodexReviewBackendModel.Review.Run,
- reason: CodexReviewBackendModel.CancellationReason
- ) async throws -> CodexReviewBackendModel.Review.RecoveryToken
- func resumeReviewRecovery(
- _ token: CodexReviewBackendModel.Review.RecoveryToken,
+ func prepareReviewRestart(_ run: CodexReviewBackendModel.Review.Run) async throws -> CodexReviewBackendModel.Review.RestartToken
+ func restartPreparedReview(
+ _ token: CodexReviewBackendModel.Review.RestartToken,
request: CodexReviewBackendModel.Review.Start
) async throws -> BackendReviewAttempt
func cleanupReview(_ run: CodexReviewBackendModel.Review.Run) async
}
+package struct CodexReviewRuntimeStopReviewCleanupRequest: Sendable {
+ package var reason: CodexReviewBackendModel.CancellationReason
+ package var recoveryWaitingRuns: [CodexReviewBackendModel.Review.Run]
+
+ package init(
+ reason: CodexReviewBackendModel.CancellationReason,
+ recoveryWaitingRuns: [CodexReviewBackendModel.Review.Run]
+ ) {
+ self.reason = reason
+ self.recoveryWaitingRuns = recoveryWaitingRuns
+ }
+}
+
+package struct CodexReviewRuntimeStopReviewCleanupResult: Sendable {
+ package var didCompleteBackendCleanup: Bool
+ package var didDrainReviewWorkers: Bool
+
+ package var didComplete: Bool {
+ didCompleteBackendCleanup && didDrainReviewWorkers
+ }
+
+ package init(
+ didCompleteBackendCleanup: Bool,
+ didDrainReviewWorkers: Bool
+ ) {
+ self.didCompleteBackendCleanup = didCompleteBackendCleanup
+ self.didDrainReviewWorkers = didDrainReviewWorkers
+ }
+}
+
extension CodexReviewStoreBackend {
- package var handlesActiveReviewStopCleanup: Bool {
+ package var invokesRuntimeStopReviewCleanupDuringStop: Bool {
false
}
}
diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift
new file mode 100644
index 00000000..a9c80b04
--- /dev/null
+++ b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift
@@ -0,0 +1,283 @@
+import Foundation
+
+private actor RuntimeStopDetachedReviewWorkerDrainRace {
+ private var result: Bool?
+ private var continuation: CheckedContinuation?
+
+ func finish(_ value: Bool) {
+ guard result == nil else {
+ return
+ }
+ result = value
+ continuation?.resume(returning: value)
+ continuation = nil
+ }
+
+ func wait() async -> Bool {
+ if let result {
+ return result
+ }
+ return await withCheckedContinuation { continuation in
+ if let result {
+ continuation.resume(returning: result)
+ } else {
+ self.continuation = continuation
+ }
+ }
+ }
+}
+
+extension CodexReviewStore {
+ package func completeCancellationLocally(
+ runID: String,
+ sessionID: String,
+ cancellation: ReviewCancellation = .system()
+ ) throws {
+ guard let runRecord = reviewRun(id: runID)
+ else {
+ throw CodexReviewAPI.Error.runNotFound("Run \(runID) was not found.")
+ }
+ guard runRecord.sessionID == sessionID
+ else {
+ throw CodexReviewAPI.Error.runNotFound("Run \(runID) was not found.")
+ }
+ guard runRecord.isTerminal == false else {
+ return
+ }
+
+ let endedAt = clock.now()
+ runRecord.cancellationRequested = false
+ runRecord.core.lifecycle.cancellation = cancellation
+ runRecord.core.lifecycle.status = .cancelled
+ runRecord.core.lifecycleMessage = cancellation.message
+ runRecord.core.lifecycle.errorMessage =
+ cancellation.message.nilIfEmpty
+ ?? runRecord.core.lifecycle.errorMessage
+ runRecord.core.lifecycle.endedAt = endedAt
+ runRecord.core.finalReview = nil
+ noteReviewRunMutation()
+ }
+
+ package func recordCancellationFailure(
+ runID: String,
+ sessionID: String,
+ message: String
+ ) throws {
+ guard let runRecord = reviewRun(id: runID)
+ else {
+ throw CodexReviewAPI.Error.runNotFound("Run \(runID) was not found.")
+ }
+ guard runRecord.sessionID == sessionID
+ else {
+ throw CodexReviewAPI.Error.runNotFound("Run \(runID) was not found.")
+ }
+
+ runRecord.cancellationRequested = false
+ runRecord.core.lifecycle.cancellation = nil
+ if let message = message.nilIfEmpty {
+ if message == "Failed to cancel review." {
+ runRecord.core.lifecycleMessage = message
+ } else {
+ runRecord.core.lifecycleMessage = "Failed to cancel review: \(message)"
+ }
+ runRecord.core.lifecycle.errorMessage = message
+ } else {
+ runRecord.core.lifecycleMessage = "Failed to cancel review."
+ }
+ writeDiagnosticsIfNeeded()
+ }
+
+ package func recordCancellationFailure(
+ runID: String,
+ message: String
+ ) throws {
+ guard let runRecord = reviewRun(id: runID)
+ else {
+ throw CodexReviewAPI.Error.runNotFound("Run \(runID) was not found.")
+ }
+ try recordCancellationFailure(
+ runID: runID,
+ sessionID: runRecord.sessionID,
+ message: message
+ )
+ }
+
+ public func cancelAllRunningReviewRuns(
+ reason: String = "Cancellation requested."
+ ) async throws {
+ let cancellation = ReviewCancellation.system(
+ message: reason.nilIfEmpty ?? "Cancellation requested."
+ )
+ let cancellableReviewRuns = orderedReviewRuns.filter(isCancellableReviewRun)
+ var firstError: (any Error)?
+ for runRecord in cancellableReviewRuns {
+ do {
+ _ = try await cancelReview(
+ runID: runRecord.id,
+ sessionID: runRecord.sessionID,
+ cancellation: cancellation
+ )
+ } catch {
+ let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
+ try? recordCancellationFailure(
+ runID: runRecord.id,
+ sessionID: runRecord.sessionID,
+ message: message.isEmpty ? "Failed to cancel review." : message
+ )
+ if firstError == nil {
+ firstError = error
+ }
+ }
+ }
+ if let firstError {
+ throw firstError
+ }
+ }
+
+ package func requestActiveReviewCancellationsForRuntimeStop(
+ reason: ReviewCancellation = .system(message: "Review runtime stopped.")
+ ) async -> [String] {
+ let activeReviewRunIDs =
+ orderedReviewRuns
+ .filter { $0.isTerminal == false }
+ .map(\.id)
+ for runID in activeReviewRunIDs {
+ _ = try? await cancelReview(runID: runID, cancellation: reason)
+ }
+ return activeReviewRunIDs
+ }
+
+ package func cleanupActiveReviewsForRuntimeStop(
+ reason: ReviewCancellation = .system(message: "Review runtime stopped."),
+ workerDrainTimeout: Duration,
+ cleanupBackendReviews:
+ @escaping @Sendable (
+ CodexReviewRuntimeStopReviewCleanupRequest
+ ) async -> Bool
+ ) async -> CodexReviewRuntimeStopReviewCleanupResult {
+ let request = runtimeStopReviewCleanupRequest(reason: reason)
+ let didCompleteBackendCleanup = await cleanupBackendReviews(request)
+ let locallyCancelledReviewRunIDs = cancelActiveReviewsLocallyForRuntimeStop(
+ reason: reason,
+ cancelWorkers: false
+ )
+ cancelAndDetachReviewWorkersForRuntimeStop(runIDs: locallyCancelledReviewRunIDs)
+ let didDrainReviewWorkers = await drainReviewWorkersForRuntimeStop(
+ timeout: workerDrainTimeout
+ )
+ return .init(
+ didCompleteBackendCleanup: didCompleteBackendCleanup,
+ didDrainReviewWorkers: didDrainReviewWorkers
+ )
+ }
+
+ private func runtimeStopReviewCleanupRequest(
+ reason: ReviewCancellation
+ ) -> CodexReviewRuntimeStopReviewCleanupRequest {
+ return .init(
+ reason: .init(message: reason.message),
+ recoveryWaitingRuns: runtimeState.recoveryWaitingRuns()
+ )
+ }
+
+ @discardableResult
+ package func cancelActiveReviewsLocallyForRuntimeStop(
+ reason: ReviewCancellation = .system(message: "Review runtime stopped."),
+ cancelWorkers: Bool = true
+ ) -> [String] {
+ let activeReviewRunIDs =
+ orderedReviewRuns
+ .filter { $0.isTerminal == false }
+ .map(\.id)
+ guard activeReviewRunIDs.isEmpty == false else {
+ return []
+ }
+
+ for runID in activeReviewRunIDs {
+ if let runRecord = reviewRun(id: runID), runRecord.isTerminal == false {
+ try? completeCancellationLocally(
+ runID: runRecord.id,
+ sessionID: runRecord.sessionID,
+ cancellation: reason
+ )
+ }
+ if cancelWorkers {
+ runtimeState.cancelActiveWorker(for: runID)
+ }
+ }
+ return activeReviewRunIDs
+ }
+
+ package func cancelAndDetachReviewWorkersForRuntimeStop(runIDs: [String]) {
+ for runID in runIDs {
+ runtimeState.cancelAndDetachActiveWorkerForRuntimeStop(runID: runID)
+ runtimeState.clearRuntimeStopState(for: runID)
+ }
+ }
+
+ package func drainRuntimeStopDetachedReviewWorkers(timeout: Duration) async -> Bool {
+ let tasks = runtimeState.detachedWorkerTasks()
+ return await drainReviewWorkerTasksForRuntimeStop(tasks, timeout: timeout)
+ }
+
+ package func drainReviewWorkersForRuntimeStop(timeout: Duration) async -> Bool {
+ let tasks = runtimeState.allWorkerTasks()
+ return await drainReviewWorkerTasksForRuntimeStop(tasks, timeout: timeout)
+ }
+
+ private func drainReviewWorkerTasksForRuntimeStop(
+ _ tasks: [Task],
+ timeout: Duration
+ ) async -> Bool {
+ guard tasks.isEmpty == false else {
+ return true
+ }
+
+ let race = RuntimeStopDetachedReviewWorkerDrainRace()
+ let drainTask = Task {
+ for task in tasks {
+ await task.value
+ }
+ await race.finish(true)
+ }
+ let timeoutTask = Task {
+ do {
+ try await Task.sleep(for: timeout)
+ } catch {
+ return
+ }
+ await race.finish(false)
+ }
+
+ let didDrain = await race.wait()
+ if didDrain {
+ timeoutTask.cancel()
+ } else {
+ drainTask.cancel()
+ }
+ return didDrain
+ }
+
+ package func terminateAllRunningReviewRunsLocally(
+ reason: String = "Cancellation requested.",
+ failureMessage: String
+ ) {
+ let resolvedError = failureMessage.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
+ for runRecord in orderedReviewRuns where runRecord.isTerminal == false {
+ runRecord.cancellationRequested = false
+ runRecord.core.lifecycle.cancellation = nil
+ runRecord.core.lifecycle.status = .failed
+ if let resolvedError {
+ runRecord.core.lifecycleMessage = "Failed to cancel review: \(resolvedError)"
+ } else {
+ runRecord.core.lifecycleMessage = "Failed to cancel review."
+ }
+ runRecord.core.lifecycle.errorMessage =
+ resolvedError
+ ?? reason.nilIfEmpty
+ ?? runRecord.core.lifecycle.errorMessage
+ runRecord.core.lifecycle.endedAt = clock.now()
+ }
+ noteReviewRunMutation()
+ }
+}
diff --git a/Sources/CodexReview/Store/CodexReviewStoreDiagnostics.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift
similarity index 76%
rename from Sources/CodexReview/Store/CodexReviewStoreDiagnostics.swift
rename to Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift
index fcca7a23..dd6d55c6 100644
--- a/Sources/CodexReview/Store/CodexReviewStoreDiagnostics.swift
+++ b/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift
@@ -2,28 +2,24 @@ import Foundation
public enum CodexReviewStoreTestEnvironment {
public static let reviewModeKey = "REVIEW_MONITOR_REVIEW_MODE"
- public static let mockJobsKey = "REVIEW_MONITOR_MOCK_JOBS"
public static let portKey = "REVIEW_MONITOR_TEST_PORT"
public static let codexCommandKey = "REVIEW_MONITOR_TEST_CODEX_COMMAND"
public static let diagnosticsPathKey = "REVIEW_MONITOR_TEST_DIAGNOSTICS_PATH"
public static let reviewModeArgument = "--review-monitor-review-mode"
- public static let mockJobsArgument = "--review-monitor-mock-jobs"
public static let portArgument = "--review-monitor-test-port"
public static let codexCommandArgument = "--review-monitor-test-codex-command"
public static let diagnosticsPathArgument = "--review-monitor-test-diagnostics-path"
}
struct CodexReviewStoreDiagnosticsSnapshot: Encodable {
- struct Job: Encodable {
+ struct Run: Encodable {
var status: String
- var summary: String
- var logText: String
- var rawLogText: String
+ var lifecycleMessage: String
}
var serverState: String
var failureMessage: String?
var serverURL: String?
var childRuntimePath: String?
- var jobs: [Job]
+ var reviewRuns: [Run]
}
diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift
new file mode 100644
index 00000000..548372c1
--- /dev/null
+++ b/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift
@@ -0,0 +1,108 @@
+package struct CodexChatCancellationCapability: Equatable, Sendable {
+ package enum Action: Equatable, Sendable {
+ case reviewRun
+ case directChat
+ }
+
+ package let isEnabled: Bool
+ package let action: Action?
+
+ package static let disabled = CodexChatCancellationCapability(isEnabled: false, action: nil)
+ package static let reviewRun = CodexChatCancellationCapability(isEnabled: true, action: .reviewRun)
+ package static let directChat = CodexChatCancellationCapability(isEnabled: true, action: .directChat)
+ // A cancellation is already in flight; there is nothing further the user
+ // can trigger, so the command stays visible but disabled.
+ package static let pendingReviewCancellation = CodexChatCancellationCapability(isEnabled: false, action: nil)
+
+ package init(isEnabled: Bool, action: Action?) {
+ self.isEnabled = isEnabled
+ self.action = action
+ }
+}
+
+extension CodexReviewStore {
+ package var orderedReviewRuns: [ReviewRunRecord] {
+ reviewRuns.sorted {
+ if $0.sortOrder == $1.sortOrder {
+ return $0.id < $1.id
+ }
+ return $0.sortOrder > $1.sortOrder
+ }
+ }
+
+ package func reviewRun(id: String) -> ReviewRunRecord? {
+ reviewRuns.first(where: { $0.id == id })
+ }
+
+ package func isCancellableReviewRun(_ runRecord: ReviewRunRecord) -> Bool {
+ runRecord.isTerminal == false && runRecord.cancellationRequested == false
+ }
+
+ package func hasCancellableReview(forChatID chatID: String) -> Bool {
+ cancellableReviewRun(forChatID: chatID) != nil
+ }
+
+ package func hasReviewRun(forChatID chatID: String) -> Bool {
+ reviewRun(forChatID: chatID) != nil
+ }
+
+ package func hasNonTerminalReviewRun(forChatID chatID: String) -> Bool {
+ orderedReviewRuns.contains { runRecord in
+ guard runRecord.isTerminal == false else {
+ return false
+ }
+ return runRecord.matchesChatID(chatID)
+ }
+ }
+
+ package func chatCancellationCapability(
+ forChatID chatID: String,
+ isChatActive: Bool
+ ) -> CodexChatCancellationCapability {
+ if hasCancellableReview(forChatID: chatID) {
+ return .reviewRun
+ }
+
+ guard isChatActive else {
+ return .disabled
+ }
+
+ if hasNonTerminalReviewRun(forChatID: chatID) {
+ return .pendingReviewCancellation
+ }
+
+ return .directChat
+ }
+
+ package func reviewRun(forChatID chatID: String) -> ReviewRunRecord? {
+ orderedReviewRuns.first { runRecord in
+ runRecord.matchesChatID(chatID)
+ }
+ }
+
+ package func cancellableReviewRun(forChatID chatID: String) -> ReviewRunRecord? {
+ orderedReviewRuns.first { runRecord in
+ guard isCancellableReviewRun(runRecord) else {
+ return false
+ }
+ return runRecord.matchesChatID(chatID)
+ }
+ }
+
+}
+
+private extension ReviewRunRecord {
+ func matchesChatID(_ chatID: String) -> Bool {
+ matchesChatID(chatID, candidate: core.run.reviewThreadID)
+ || matchesChatID(chatID, candidate: core.run.threadID)
+ }
+
+ private func matchesChatID(_ chatID: String, candidate: String?) -> Bool {
+ guard let candidate = candidate?.trimmingCharacters(in: .whitespacesAndNewlines),
+ candidate.isEmpty == false
+ else {
+ return false
+ }
+ return candidate == chatID
+ }
+}
diff --git a/Sources/CodexReview/Store/CodexReviewStoreRateLimitAutoRefresh.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreRateLimitAutoRefresh.swift
similarity index 95%
rename from Sources/CodexReview/Store/CodexReviewStoreRateLimitAutoRefresh.swift
rename to Sources/CodexReviewKit/Store/CodexReviewStoreRateLimitAutoRefresh.swift
index 560b289c..a20add47 100644
--- a/Sources/CodexReview/Store/CodexReviewStoreRateLimitAutoRefresh.swift
+++ b/Sources/CodexReviewKit/Store/CodexReviewStoreRateLimitAutoRefresh.swift
@@ -36,7 +36,7 @@ package struct CodexReviewStoreRateLimitAutoRefreshTarget: Equatable, Sendable {
private struct CodexReviewStoreRateLimitAutoRefreshContext {
var selectedAccountKey: String?
- var hasRunningJobs: Bool
+ var hasRunningReviewRuns: Bool
var serverState: CodexReviewServerState
var now: Date
}
@@ -50,7 +50,7 @@ private struct CodexReviewStoreRateLimitAutoRefreshPolicy {
var noProgressRefreshRetryDelay: TimeInterval = 60
func targets(
- accounts: [CodexAccount],
+ accounts: [CodexReviewAccount],
context: CodexReviewStoreRateLimitAutoRefreshContext
) -> [CodexReviewStoreRateLimitAutoRefreshTarget] {
guard case .running = context.serverState else {
@@ -68,7 +68,7 @@ private struct CodexReviewStoreRateLimitAutoRefreshPolicy {
}
private func target(
- for account: CodexAccount,
+ for account: CodexReviewAccount,
context: CodexReviewStoreRateLimitAutoRefreshContext
) -> CodexReviewStoreRateLimitAutoRefreshTarget? {
guard account.capabilities.supportsRateLimitRefresh else {
@@ -85,14 +85,14 @@ private struct CodexReviewStoreRateLimitAutoRefreshPolicy {
}
private func intervalTarget(
- for account: CodexAccount,
+ for account: CodexReviewAccount,
context: CodexReviewStoreRateLimitAutoRefreshContext
) -> CodexReviewStoreRateLimitAutoRefreshTarget {
let kind: CodexReviewStoreRateLimitAutoRefreshTarget.Kind
let interval: TimeInterval
switch role(for: account, context: context) {
case .selected:
- if context.hasRunningJobs {
+ if context.hasRunningReviewRuns {
kind = .selectedRunningInterval
interval = selectedRunningInterval
} else {
@@ -111,7 +111,7 @@ private struct CodexReviewStoreRateLimitAutoRefreshPolicy {
)
}
- private func resetTarget(for account: CodexAccount) -> CodexReviewStoreRateLimitAutoRefreshTarget? {
+ private func resetTarget(for account: CodexReviewAccount) -> CodexReviewStoreRateLimitAutoRefreshTarget? {
nextUnconsumedResetRefreshDate(for: account).map {
.init(
accountKey: account.accountKey,
@@ -122,13 +122,13 @@ private struct CodexReviewStoreRateLimitAutoRefreshPolicy {
}
private func role(
- for account: CodexAccount,
+ for account: CodexReviewAccount,
context: CodexReviewStoreRateLimitAutoRefreshContext
) -> CodexReviewStoreRateLimitAutoRefreshRole {
account.accountKey == context.selectedAccountKey ? .selected : .background
}
- private func nextUnconsumedResetRefreshDate(for account: CodexAccount) -> Date? {
+ private func nextUnconsumedResetRefreshDate(for account: CodexReviewAccount) -> Date? {
account.rateLimits
.compactMap { window -> Date? in
guard let resetsAt = window.resetsAt else {
@@ -163,7 +163,7 @@ private struct CodexReviewStoreRateLimitAutoRefreshAccountState {
mutating func scheduledTarget(
from target: CodexReviewStoreRateLimitAutoRefreshTarget,
- account: CodexAccount,
+ account: CodexReviewAccount,
now: Date
) -> CodexReviewStoreRateLimitAutoRefreshTarget {
if let suppressedUntil, suppressedUntil <= now {
@@ -233,7 +233,7 @@ extension CodexReviewStore {
CodexReviewStoreRateLimitAutoRefreshDriver.targets(
accounts: auth.accounts,
selectedAccountKey: auth.selectedAccount?.accountKey,
- hasRunningJobs: hasRunningJobs,
+ hasRunningReviewRuns: hasRunningReviewRuns,
serverState: serverState,
now: now
)
@@ -273,9 +273,9 @@ package final class CodexReviewStoreRateLimitAutoRefreshDriver {
}
package static func targets(
- accounts: [CodexAccount],
+ accounts: [CodexReviewAccount],
selectedAccountKey: String?,
- hasRunningJobs: Bool,
+ hasRunningReviewRuns: Bool,
serverState: CodexReviewServerState,
now: Date
) -> [CodexReviewStoreRateLimitAutoRefreshTarget] {
@@ -283,7 +283,7 @@ package final class CodexReviewStoreRateLimitAutoRefreshDriver {
accounts: accounts,
context: .init(
selectedAccountKey: selectedAccountKey,
- hasRunningJobs: hasRunningJobs,
+ hasRunningReviewRuns: hasRunningReviewRuns,
serverState: serverState,
now: now
)
diff --git a/Sources/CodexReview/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift
similarity index 55%
rename from Sources/CodexReview/Store/CodexReviewStoreReviews.swift
rename to Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift
index 3672666b..3763bc68 100644
--- a/Sources/CodexReview/Store/CodexReviewStoreReviews.swift
+++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift
@@ -1,13 +1,11 @@
import Foundation
-import CodexReviewApplication
-import CodexReviewDomain
private let networkRecoveryUnavailableMessage = "Network unavailable; waiting to reconnect."
private let networkRecoveryRestoredMessage = "Network restored; restarting review."
extension CodexReviewStore {
- package func activeJobIDs(for sessionID: String) -> [String] {
- orderedJobs
+ package func activeReviewRunIDs(for sessionID: String) -> [String] {
+ orderedReviewRuns
.filter { $0.sessionID == sessionID && $0.isTerminal == false }
.map(\.id)
}
@@ -17,14 +15,14 @@ extension CodexReviewStore {
sessionID: String,
request: CodexReviewAPI.Start.Request
) async throws -> CodexReviewAPI.Read.Result {
- let jobID = try beginReview(sessionID: sessionID, request: request)
+ let runID = try beginReview(sessionID: sessionID, request: request)
return try await withTaskCancellationHandler {
- _ = try await awaitReview(sessionID: sessionID, jobID: jobID)
- await reviewWorkerTasks[jobID]?.value
- return try readReview(sessionID: sessionID, jobID: jobID)
+ _ = try await awaitReview(sessionID: sessionID, runID: runID)
+ await runtimeState.awaitActiveWorker(for: runID)
+ return try readReview(sessionID: sessionID, runID: runID)
} onCancel: {
Task { @MainActor [weak self] in
- self?.reviewWorkerTasks[jobID]?.cancel()
+ self?.runtimeState.cancelActiveWorker(for: runID)
}
}
}
@@ -35,23 +33,32 @@ extension CodexReviewStore {
request: CodexReviewAPI.Start.Request,
waitTimeout: Duration
) async throws -> CodexReviewAPI.Read.Result {
- let jobID = try beginReview(sessionID: sessionID, request: request)
- return try await awaitReview(sessionID: sessionID, jobID: jobID, timeout: waitTimeout)
+ let runID = try beginReview(sessionID: sessionID, request: request)
+ // A timeout returns while the worker keeps running (clients re-await
+ // by runId), but caller cancellation must cancel the worker like the
+ // unbounded overload, or disconnected clients orphan the review.
+ return try await withTaskCancellationHandler {
+ try await awaitReview(sessionID: sessionID, runID: runID, timeout: waitTimeout)
+ } onCancel: {
+ Task { @MainActor [weak self] in
+ self?.runtimeState.cancelActiveWorker(for: runID)
+ }
+ }
}
package func awaitReview(
sessionID: String?,
- jobID: String,
+ runID: String,
timeout: Duration? = nil
) async throws -> CodexReviewAPI.Read.Result {
- let job = try job(jobID: jobID)
- if let sessionID, job.sessionID != sessionID {
- throw CodexReviewAPI.Error.jobNotFound("Job \(jobID) was not found.")
+ let runRecord = try requireReviewRun(runID: runID)
+ if let sessionID, runRecord.sessionID != sessionID {
+ throw CodexReviewAPI.Error.runNotFound("Run \(runID) was not found.")
}
- if job.isTerminal == false {
- await waitForReviewTerminal(jobID: jobID, timeout: timeout)
+ if runRecord.isTerminal == false {
+ await waitForReviewTerminal(runID: runID, timeout: timeout)
}
- return try readReview(sessionID: sessionID, jobID: jobID)
+ return try readReview(sessionID: sessionID, runID: runID)
}
@discardableResult
@@ -64,50 +71,50 @@ extension CodexReviewStore {
}
let validatedRequest = try request.validated()
- let jobID = idGenerator.next()
+ let runID = idGenerator.next()
let createdAt = clock.now()
- let job = CodexReviewJob(
- id: jobID,
+ let runRecord = ReviewRunRecord(
+ id: runID,
sessionID: sessionID,
cwd: validatedRequest.cwd,
- sortOrder: nextJobSortOrder(inWorkspace: validatedRequest.cwd),
+ sortOrder: nextReviewRunSortOrder(),
targetSummary: validatedRequest.target.displaySummary,
core: .init(
lifecycle: .init(status: .queued),
- output: .init(summary: "Queued.")
- ),
- logEntries: []
+ lifecycleMessage: "Queued."
+ )
)
- insertReviewJob(job)
- markReviewRunning(job, startedAt: createdAt)
- startingJobIDs.insert(jobID)
- launchReviewWorker(jobID: jobID, sessionID: sessionID, request: validatedRequest)
- return jobID
+ insertReviewRun(runRecord)
+ markReviewRunning(runRecord, startedAt: createdAt)
+ runtimeState.markStarting(runID)
+ launchReviewWorker(runID: runID, sessionID: sessionID, request: validatedRequest)
+ return runID
}
private func launchReviewWorker(
- jobID: String,
+ runID: String,
sessionID: String,
request: CodexReviewAPI.Start.Request
) {
- reviewWorkerTasks[jobID]?.cancel()
- reviewWorkerTasks[jobID] = Task { [weak self] in
- await self?.runReviewWorker(jobID: jobID, sessionID: sessionID, request: request)
- }
+ runtimeState.cancelActiveWorker(for: runID)
+ runtimeState.setActiveWorker(
+ Task { [weak self] in
+ await self?.runReviewWorker(runID: runID, sessionID: sessionID, request: request)
+ }, for: runID)
}
private func runReviewWorker(
- jobID: String,
+ runID: String,
sessionID: String,
request validatedRequest: CodexReviewAPI.Start.Request
) async {
- guard let job = job(id: jobID) else {
- startingJobIDs.remove(jobID)
- reviewWorkerTasks.removeValue(forKey: jobID)
+ guard let runRecord = reviewRun(id: runID) else {
+ runtimeState.clearStarting(runID)
+ runtimeState.removeActiveWorker(for: runID)
return
}
let startRequest = CodexReviewBackendModel.Review.Start(
- jobID: jobID,
+ runID: runID,
sessionID: sessionID,
request: validatedRequest,
model: settings.effectiveModel
@@ -116,119 +123,118 @@ extension CodexReviewStore {
do {
let backendAttempt = try await backend.startReview(startRequest)
let backendRun = backendAttempt.run
- startingJobIDs.remove(jobID)
+ runtimeState.clearStarting(runID)
run = backendRun
if Task.isCancelled {
throw CancellationError()
}
- applyBackendRun(backendRun, to: job)
- if let startupCancellation = startupCancellations.removeValue(forKey: jobID) {
+ applyBackendRun(backendRun, to: runRecord)
+ if let startupCancellation = runtimeState.takeStartupCancellation(for: runID) {
try? await backend.interruptReview(
backendRun,
reason: .init(message: startupCancellation.message)
)
- if job.isTerminal == false {
+ if runRecord.isTerminal == false {
try completeCancellationLocally(
- jobID: job.id,
- sessionID: job.sessionID,
+ runID: runRecord.id,
+ sessionID: runRecord.sessionID,
cancellation: startupCancellation
)
}
- } else if job.cancellationRequested {
+ } else if runRecord.cancellationRequested {
try await backend.interruptReview(
backendRun,
- reason: .init(message: job.core.lifecycle.cancellation?.message ?? "Cancellation requested.")
+ reason: .init(message: runRecord.core.lifecycle.cancellation?.message ?? "Cancellation requested.")
)
try completeCancellationLocally(
- jobID: job.id,
- sessionID: job.sessionID,
- cancellation: job.core.lifecycle.cancellation ?? .system()
+ runID: runRecord.id,
+ sessionID: runRecord.sessionID,
+ cancellation: runRecord.core.lifecycle.cancellation ?? .system()
)
}
- if job.isTerminal {
+ if runRecord.isTerminal {
await backend.cleanupReview(backendRun)
- activeRuns.removeValue(forKey: jobID)
- reviewRecoveryWaitingJobIDs.remove(jobID)
+ runtimeState.clearReviewRunState(for: runID)
} else {
let currentRun = try await consumeReviewEvents(
for: backendAttempt,
- job: job,
+ runRecord: runRecord,
startRequest: startRequest
)
run = currentRun
await backend.cleanupReview(currentRun)
- activeRuns.removeValue(forKey: jobID)
- reviewRecoveryWaitingJobIDs.remove(jobID)
+ runtimeState.clearReviewRunState(for: runID)
}
} catch let error where error is CancellationError || Task.isCancelled {
- startingJobIDs.remove(jobID)
- let startupCancellation = startupCancellations.removeValue(forKey: jobID)
- if let cleanupRun = activeRuns[jobID] ?? run {
- await interruptReviewAfterTaskCancellation(cleanupRun, job: job)
+ runtimeState.clearStarting(runID)
+ let startupCancellation = runtimeState.takeStartupCancellation(for: runID)
+ if let cleanupRun = runtimeState.activeRun(for: runID) ?? run {
+ await interruptReviewAfterTaskCancellation(cleanupRun, runRecord: runRecord)
await backend.cleanupReview(cleanupRun)
- } else if job.isTerminal == false || startupCancellation != nil {
+ } else if runRecord.isTerminal == false || startupCancellation != nil {
try? completeCancellationLocally(
- jobID: job.id,
- sessionID: job.sessionID,
- cancellation: startupCancellation ?? job.core.lifecycle.cancellation ?? .system()
+ runID: runRecord.id,
+ sessionID: runRecord.sessionID,
+ cancellation: startupCancellation ?? runRecord.core.lifecycle.cancellation ?? .system()
)
}
- activeRuns.removeValue(forKey: jobID)
- reviewRecoveryWaitingJobIDs.remove(jobID)
+ runtimeState.clearReviewRunState(for: runID)
} catch {
- startingJobIDs.remove(jobID)
- let startupCancellation = startupCancellations.removeValue(forKey: jobID)
- if let cleanupRun = activeRuns[jobID] ?? run {
+ runtimeState.clearStarting(runID)
+ let startupCancellation = runtimeState.takeStartupCancellation(for: runID)
+ if let cleanupRun = runtimeState.activeRun(for: runID) ?? run {
await backend.cleanupReview(cleanupRun)
}
- activeRuns.removeValue(forKey: jobID)
- reviewRecoveryWaitingJobIDs.remove(jobID)
- if job.isTerminal == false, let startupCancellation {
+ runtimeState.clearReviewRunState(for: runID)
+ if runRecord.isTerminal == false, let startupCancellation {
try? completeCancellationLocally(
- jobID: job.id,
- sessionID: job.sessionID,
+ runID: runRecord.id,
+ sessionID: runRecord.sessionID,
cancellation: startupCancellation
)
- } else if job.isTerminal == false {
- markReviewFailed(job, message: error.localizedDescription)
+ } else if runRecord.isTerminal == false {
+ markReviewFailed(runRecord, message: error.localizedDescription)
}
}
- reviewWorkerTasks.removeValue(forKey: jobID)
- runtimeStopDetachedReviewWorkerTasks.removeValue(forKey: jobID)
+ runtimeState.removeActiveWorker(for: runID)
+ runtimeState.removeDetachedWorker(for: runID)
}
- private func interruptReviewAfterTaskCancellation(_ run: CodexReviewBackendModel.Review.Run, job: CodexReviewJob) async {
- guard job.isTerminal == false else {
+ private func interruptReviewAfterTaskCancellation(_ run: CodexReviewBackendModel.Review.Run, runRecord: ReviewRunRecord)
+ async
+ {
+ guard runRecord.isTerminal == false else {
return
}
- let cancellation = job.core.lifecycle.cancellation ?? .system()
- job.cancellationRequested = true
- job.core.lifecycle.cancellation = cancellation
- job.core.output.summary = cancellation.message
- job.core.lifecycle.errorMessage = cancellation.message
+ let cancellation = runRecord.core.lifecycle.cancellation ?? .system()
+ runRecord.cancellationRequested = true
+ runRecord.core.lifecycle.cancellation = cancellation
+ runRecord.core.lifecycleMessage = cancellation.message
+ runRecord.core.lifecycle.errorMessage = cancellation.message
do {
try await backend.interruptReview(
run,
reason: .init(message: cancellation.message)
)
try completeCancellationLocally(
- jobID: job.id,
- sessionID: job.sessionID,
+ runID: runRecord.id,
+ sessionID: runRecord.sessionID,
cancellation: cancellation
)
} catch {
try? recordCancellationFailure(
- jobID: job.id,
- sessionID: job.sessionID,
+ runID: runRecord.id,
+ sessionID: runRecord.sessionID,
message: error.localizedDescription
)
}
}
- private func applyBackendRun(_ backendRun: CodexReviewBackendModel.Review.Run, to job: CodexReviewJob) {
- activeRuns[job.id] = backendRun
- job.core.run = .init(
+ private func applyBackendRun(_ backendRun: CodexReviewBackendModel.Review.Run, to runRecord: ReviewRunRecord) {
+ runtimeState.setActiveRun(backendRun, for: runRecord.id)
+ runRecord.core.run = .init(
+ attemptID: backendRun.attemptID,
reviewThreadID: backendRun.reviewThreadID,
threadID: backendRun.threadID,
turnID: backendRun.turnID,
@@ -237,18 +243,13 @@ extension CodexReviewStore {
writeDiagnosticsIfNeeded()
}
- private func appendRecoveryProgress(_ message: String, to job: CodexReviewJob) {
- job.core.output.summary = message
- job.appendLogEntry(.init(kind: .progress, text: message, timestamp: clock.now()))
- job.applyReviewLogLimit()
+ private func appendRecoveryProgress(_ message: String, to runRecord: ReviewRunRecord) {
+ runRecord.core.lifecycleMessage = message
writeDiagnosticsIfNeeded()
}
- private func markReviewWaitingForNetworkRecovery(_ job: CodexReviewJob) {
- let now = clock.now()
- job.closeActiveCommandLogEntries(status: "canceled", completedAt: now)
- job.resetReviewAttemptOutputForRecovery()
- appendRecoveryProgress(networkRecoveryUnavailableMessage, to: job)
+ private func markReviewWaitingForNetworkRecovery(_ runRecord: ReviewRunRecord) {
+ appendRecoveryProgress(networkRecoveryUnavailableMessage, to: runRecord)
}
private func reviewWorkerInputs(for attempt: BackendReviewAttempt) async -> ReviewWorkerInputs {
@@ -279,46 +280,32 @@ extension CodexReviewStore {
)
}
- package func readReview(
- jobID: String,
- logFilter: CodexReviewAPI.Log.Filter = .defaultSetting,
- logPage: CodexReviewAPI.Log.PageRequest = .default
- ) throws -> CodexReviewAPI.Read.Result {
- try readReview(sessionID: nil, jobID: jobID, logFilter: logFilter, logPage: logPage)
+ package func readReview(runID: String) throws -> CodexReviewAPI.Read.Result {
+ try readReview(sessionID: nil, runID: runID)
}
package func readReview(
sessionID: String?,
- jobID: String,
- logFilter: CodexReviewAPI.Log.Filter = .defaultSetting,
- logPage: CodexReviewAPI.Log.PageRequest = .default
+ runID: String
) throws -> CodexReviewAPI.Read.Result {
- let job = try job(jobID: jobID)
- if let sessionID, job.sessionID != sessionID {
- throw CodexReviewAPI.Error.jobNotFound("Job \(jobID) was not found.")
- }
- let pageRequest = try logPage.validated()
- let timelineLogs = job.timelineLogEntries
- let logSource = timelineLogs.isEmpty ? job.logEntries : timelineLogs
- let filteredLogs = projectedLogsForReviewRead(logSource).filter(logFilter.includes)
- let page = pageRequest.page(total: filteredLogs.count)
+ let runRecord = try requireReviewRun(runID: runID)
+ if let sessionID, runRecord.sessionID != sessionID {
+ throw CodexReviewAPI.Error.runNotFound("Run \(runID) was not found.")
+ }
return CodexReviewAPI.Read.Result(
- jobID: job.id,
- core: job.core,
- elapsedSeconds: elapsedSeconds(for: job),
- cancellable: job.isTerminal == false && job.cancellationRequested == false,
- logs: Array(filteredLogs[page.offset.. CodexReviewAPI.List.Result {
- let filtered = filteredJobs(cwd: cwd, statuses: statuses)
+ let filtered = filteredReviewRuns(cwd: cwd, statuses: statuses)
let clampedLimit = min(max(limit ?? 20, 1), 100)
return CodexReviewAPI.List.Result(items: Array(filtered.prefix(clampedLimit)).map(makeListItem))
}
@@ -326,18 +313,18 @@ extension CodexReviewStore {
package func listReviews(
sessionID: String?,
cwd: String? = nil,
- statuses: [ReviewJobState]? = nil,
+ statuses: [ReviewRunState]? = nil,
limit: Int? = nil
) -> CodexReviewAPI.List.Result {
let statusSet = statuses.map(Set.init)
- let filtered = orderedJobs.filter { job in
- if let sessionID, job.sessionID != sessionID {
+ let filtered = orderedReviewRuns.filter { runRecord in
+ if let sessionID, runRecord.sessionID != sessionID {
return false
}
- if let cwd, job.cwd != cwd {
+ if let cwd, runRecord.cwd != cwd {
return false
}
- if let statusSet, statusSet.contains(job.core.lifecycle.status) == false {
+ if let statusSet, statusSet.contains(runRecord.core.lifecycle.status) == false {
return false
}
return true
@@ -346,137 +333,148 @@ extension CodexReviewStore {
return CodexReviewAPI.List.Result(items: Array(filtered.prefix(clampedLimit)).map(makeListItem))
}
- package func resolveJob(selector: CodexReviewAPI.Job.Selector) throws -> CodexReviewJob {
- try resolveJob(sessionID: nil, selector: selector)
+ package func resolveRun(selector: CodexReviewAPI.Run.Selector) throws -> ReviewRunRecord {
+ try resolveRun(sessionID: nil, selector: selector)
}
- package func resolveJob(sessionID: String?, selector: CodexReviewAPI.Job.Selector) throws -> CodexReviewJob {
+ package func resolveRun(sessionID: String?, selector: CodexReviewAPI.Run.Selector) throws -> ReviewRunRecord {
let statusSet = selector.statuses.map(Set.init)
- let matches = orderedJobs.filter { job in
- if let sessionID, job.sessionID != sessionID {
+ let matches = orderedReviewRuns.filter { runRecord in
+ if let sessionID, runRecord.sessionID != sessionID {
return false
}
- if let cwd = selector.cwd, job.cwd != cwd {
+ if let cwd = selector.cwd, runRecord.cwd != cwd {
return false
}
- if let statusSet, statusSet.contains(job.core.lifecycle.status) == false {
+ if let statusSet, statusSet.contains(runRecord.core.lifecycle.status) == false {
return false
}
- if let jobID = selector.jobID, jobID != job.id {
+ if let runID = selector.runID, runID != runRecord.id {
return false
}
return true
}
- if let job = matches.first, matches.count == 1 {
- return job
+ if let runRecord = matches.first, matches.count == 1 {
+ return runRecord
}
if matches.isEmpty {
- throw CodexReviewAPI.Error.jobNotFound("No review job matched the selector.")
+ throw CodexReviewAPI.Error.runNotFound("No review run matched the selector.")
}
- throw CodexReviewAPI.Job.SelectionError.ambiguous(matches.map(makeListItem))
+ throw CodexReviewAPI.Run.SelectionError.ambiguous(matches.map(makeListItem))
}
package func cancelReview(
- jobID: String,
+ runID: String,
sessionID: String,
cancellation: ReviewCancellation = .system()
) async throws -> CodexReviewAPI.Cancel.Outcome {
- guard let job = job(id: jobID), job.sessionID == sessionID else {
- throw CodexReviewAPI.Error.jobNotFound("Job \(jobID) was not found.")
+ guard let runRecord = reviewRun(id: runID), runRecord.sessionID == sessionID else {
+ throw CodexReviewAPI.Error.runNotFound("Run \(runID) was not found.")
}
- return try await cancelReview(jobID: jobID, cancellation: cancellation)
+ return try await cancelReview(runID: runID, cancellation: cancellation)
}
@discardableResult
package func cancelReview(
- jobID: String,
+ runID: String,
cancellation: ReviewCancellation = .system()
) async throws -> CodexReviewAPI.Cancel.Outcome {
- let job = try job(jobID: jobID)
- guard job.isTerminal == false else {
- return .init(jobID: job.id, cancelled: false, core: job.core)
+ let runRecord = try requireReviewRun(runID: runID)
+ guard isCancellableReviewRun(runRecord) else {
+ return .init(runID: runRecord.id, cancelled: false, core: runRecord.core)
}
- job.cancellationRequested = true
- job.core.lifecycle.cancellation = cancellation
- job.core.output.summary = cancellation.message
- job.core.lifecycle.errorMessage = cancellation.message
+ runRecord.cancellationRequested = true
+ runRecord.core.lifecycle.cancellation = cancellation
+ runRecord.core.lifecycleMessage = cancellation.message
+ runRecord.core.lifecycle.errorMessage = cancellation.message
- if job.core.lifecycle.status == .queued {
+ if runRecord.core.lifecycle.status == .queued {
try completeCancellationLocally(
- jobID: job.id,
- sessionID: job.sessionID,
+ runID: runRecord.id,
+ sessionID: runRecord.sessionID,
cancellation: cancellation
)
- return .init(jobID: job.id, cancelled: true, core: job.core)
+ return .init(runID: runRecord.id, cancelled: true, core: runRecord.core)
}
- if reviewRecoveryWaitingJobIDs.contains(jobID) {
+ if runtimeState.isWaitingForNetworkRecovery(runID) {
try completeCancellationLocally(
- jobID: job.id,
- sessionID: job.sessionID,
+ runID: runRecord.id,
+ sessionID: runRecord.sessionID,
cancellation: cancellation
)
- reviewWorkerTasks[jobID]?.cancel()
- return .init(jobID: job.id, cancelled: true, core: job.core)
+ runtimeState.cancelActiveWorker(for: runID)
+ return .init(runID: runRecord.id, cancelled: true, core: runRecord.core)
}
- if let run = activeRuns[jobID] {
+ if let run = runtimeState.activeRun(for: runID) {
do {
try await backend.interruptReview(
run,
reason: .init(message: cancellation.message)
)
try completeCancellationLocally(
- jobID: job.id,
- sessionID: job.sessionID,
+ runID: runRecord.id,
+ sessionID: runRecord.sessionID,
cancellation: cancellation
)
- reviewWorkerTasks[jobID]?.cancel()
+ runtimeState.cancelActiveWorker(for: runID)
} catch {
try recordCancellationFailure(
- jobID: job.id,
- sessionID: job.sessionID,
+ runID: runRecord.id,
+ sessionID: runRecord.sessionID,
message: error.localizedDescription
)
throw error
}
- } else if let run = job.backendRun {
+ } else if let run = runRecord.backendRun {
do {
try await backend.interruptReview(
run,
reason: .init(message: cancellation.message)
)
try completeCancellationLocally(
- jobID: job.id,
- sessionID: job.sessionID,
+ runID: runRecord.id,
+ sessionID: runRecord.sessionID,
cancellation: cancellation
)
- reviewWorkerTasks[jobID]?.cancel()
+ runtimeState.cancelActiveWorker(for: runID)
} catch {
try recordCancellationFailure(
- jobID: job.id,
- sessionID: job.sessionID,
+ runID: runRecord.id,
+ sessionID: runRecord.sessionID,
message: error.localizedDescription
)
throw error
}
- } else if startingJobIDs.contains(jobID) {
- startupCancellations[jobID] = cancellation
+ } else if runtimeState.isStarting(runID) {
+ runtimeState.setStartupCancellation(cancellation, for: runID)
try completeCancellationLocally(
- jobID: job.id,
- sessionID: job.sessionID,
+ runID: runRecord.id,
+ sessionID: runRecord.sessionID,
cancellation: cancellation
)
- return .init(jobID: job.id, cancelled: true, core: job.core)
+ return .init(runID: runRecord.id, cancelled: true, core: runRecord.core)
} else {
try completeCancellationLocally(
- jobID: job.id,
- sessionID: job.sessionID,
+ runID: runRecord.id,
+ sessionID: runRecord.sessionID,
cancellation: cancellation
)
}
- return .init(jobID: job.id, cancelled: true, core: job.core)
+ return .init(runID: runRecord.id, cancelled: true, core: runRecord.core)
+ }
+
+ @discardableResult
+ package func cancelReview(
+ chatID: String,
+ cancellation: ReviewCancellation = .system()
+ ) async throws -> CodexReviewAPI.Cancel.Outcome? {
+ guard let runRecord = cancellableReviewRun(forChatID: chatID) else {
+ return nil
+ }
+ return try await cancelReview(runID: runRecord.id, cancellation: cancellation)
}
package func closeSession(
@@ -484,106 +482,89 @@ extension CodexReviewStore {
reason: ReviewCancellation = .sessionClosed()
) async {
closedSessions.insert(sessionID)
- for jobID in activeJobIDs(for: sessionID) {
- _ = try? await cancelReview(jobID: jobID, cancellation: reason)
+ for runID in activeReviewRunIDs(for: sessionID) {
+ _ = try? await cancelReview(runID: runID, cancellation: reason)
}
}
package func closeActiveReviewSessions(reason: ReviewCancellation) async {
- let jobIDs = orderedJobs
+ let runIDs =
+ orderedReviewRuns
.filter { $0.isTerminal == false }
.map(\.id)
- for jobID in jobIDs {
- _ = try? await cancelReview(jobID: jobID, cancellation: reason)
+ for runID in runIDs {
+ _ = try? await cancelReview(runID: runID, cancellation: reason)
}
}
- private func job(jobID: String) throws -> CodexReviewJob {
- guard let job = job(id: jobID) else {
- throw CodexReviewAPI.Error.jobNotFound("Job \(jobID) was not found.")
+ private func requireReviewRun(runID: String) throws -> ReviewRunRecord {
+ guard let runRecord = reviewRun(id: runID) else {
+ throw CodexReviewAPI.Error.runNotFound("Run \(runID) was not found.")
}
- return job
+ return runRecord
}
- private func filteredJobs(cwd: String?, statuses: [ReviewJobState]?) -> [CodexReviewJob] {
+ private func filteredReviewRuns(cwd: String?, statuses: [ReviewRunState]?) -> [ReviewRunRecord] {
let statusSet = statuses.map(Set.init)
- return orderedJobs.filter { job in
- if let cwd, job.cwd != cwd {
+ return orderedReviewRuns.filter { runRecord in
+ if let cwd, runRecord.cwd != cwd {
return false
}
- if let statusSet, statusSet.contains(job.core.lifecycle.status) == false {
+ if let statusSet, statusSet.contains(runRecord.core.lifecycle.status) == false {
return false
}
return true
}
}
- private func makeListItem(_ job: CodexReviewJob) -> CodexReviewAPI.Job.ListItem {
- CodexReviewAPI.Job.ListItem(
- jobID: job.id,
- cwd: job.cwd,
- targetSummary: job.targetSummary,
- core: job.core,
- elapsedSeconds: elapsedSeconds(for: job),
- cancellable: job.isTerminal == false && job.cancellationRequested == false
+ private func makeListItem(_ runRecord: ReviewRunRecord) -> CodexReviewAPI.Run.ListItem {
+ CodexReviewAPI.Run.ListItem(
+ runID: runRecord.id,
+ cwd: runRecord.cwd,
+ targetSummary: runRecord.targetSummary,
+ core: runRecord.core,
+ elapsedSeconds: elapsedSeconds(for: runRecord),
+ cancellable: isCancellableReviewRun(runRecord)
)
}
- private func elapsedSeconds(for job: CodexReviewJob) -> Int? {
- guard let startedAt = job.core.lifecycle.startedAt else {
+ private func elapsedSeconds(for runRecord: ReviewRunRecord) -> Int? {
+ guard let startedAt = runRecord.core.lifecycle.startedAt else {
return nil
}
- let end = job.core.lifecycle.endedAt ?? clock.now()
+ let end = runRecord.core.lifecycle.endedAt ?? clock.now()
return max(0, Int(end.timeIntervalSince(startedAt)))
}
- private func insertReviewJob(_ job: CodexReviewJob) {
- if workspace(cwd: job.cwd) == nil {
- let workspace = CodexReviewWorkspace(
- cwd: job.cwd,
- sortOrder: nextWorkspaceSortOrder()
- )
- workspaces.insert(workspace)
- }
- jobs.insert(job)
+ private func insertReviewRun(_ runRecord: ReviewRunRecord) {
+ reviewRuns.insert(runRecord)
writeDiagnosticsIfNeeded()
}
- private func markReviewRunning(_ job: CodexReviewJob, startedAt: Date) {
- job.core.lifecycle.status = .running
- job.core.lifecycle.startedAt = startedAt
- job.core.output.summary = "Review started."
- job.timeline.apply(.runStarted(
- turnID: .init(rawValue: job.core.run.turnID ?? job.id),
- reviewThreadID: job.core.run.reviewThreadID.map(ReviewThread.ID.init(rawValue:)),
- model: job.core.run.model
- ), at: startedAt)
+ private func markReviewRunning(_ runRecord: ReviewRunRecord, startedAt: Date) {
+ runRecord.core.lifecycle.status = .running
+ runRecord.core.lifecycle.startedAt = startedAt
+ runRecord.core.lifecycleMessage = "Review started."
+ runRecord.core.finalReview = nil
writeDiagnosticsIfNeeded()
}
- private func markReviewFailed(_ job: CodexReviewJob, message: String) {
- guard job.isTerminal == false else {
+ private func markReviewFailed(_ runRecord: ReviewRunRecord, message: String) {
+ guard runRecord.isTerminal == false else {
return
}
let endedAt = clock.now()
- job.closeActiveCommandLogEntries(status: "failed", completedAt: endedAt)
- job.core.lifecycle.status = .failed
- job.core.lifecycle.endedAt = endedAt
- job.core.lifecycle.errorMessage = message
- job.core.output.summary = message
- let suppressErrorLogTimelineProjection = job.consumeTerminalFailureLogTimelineProjectionSuppression()
- job.appendLogEntry(
- .init(kind: .error, text: message, timestamp: endedAt),
- suppressTimelineProjection: suppressErrorLogTimelineProjection
- )
- job.timeline.apply(.reviewFailed(message), at: endedAt)
- job.applyReviewLogLimit()
+ runRecord.core.lifecycle.status = .failed
+ runRecord.core.lifecycle.endedAt = endedAt
+ runRecord.core.lifecycle.errorMessage = message
+ runRecord.core.lifecycleMessage = message
+ runRecord.core.finalReview = nil
writeDiagnosticsIfNeeded()
}
private func consumeReviewEvents(
for initialAttempt: BackendReviewAttempt,
- job: CodexReviewJob,
+ runRecord: ReviewRunRecord,
startRequest: CodexReviewBackendModel.Review.Start
) async throws -> CodexReviewBackendModel.Review.Run {
let inputs = await reviewWorkerInputs(for: initialAttempt)
@@ -593,22 +574,22 @@ extension CodexReviewStore {
var recoveryState = ReviewNetworkRecoveryLoopState(currentRun: initialAttempt.run)
var activeEventSubscriptionID: Int? = inputs.initialEventSubscriptionID
while let input = await inputs.next() {
- if job.isTerminal {
+ if runRecord.isTerminal {
return recoveryState.currentRun
}
switch input {
case .reviewEvent(let event):
guard activeEventSubscriptionID == event.subscriptionID,
- recoveryState.shouldConsumeEvent(from: event.subscriptionRun)
+ recoveryState.shouldConsumeEvent(from: event.subscriptionRun)
else {
continue
}
recoveryState.currentRun = handleReviewEvent(
event.event,
- job: job,
+ runRecord: runRecord,
currentRun: recoveryState.currentRun
)
- if job.isTerminal {
+ if runRecord.isTerminal {
return recoveryState.currentRun
}
case .reviewEventsFinished(let finishedRun):
@@ -619,14 +600,14 @@ extension CodexReviewStore {
continue
}
if try handleReviewEventsFinished(
- job: job,
+ runRecord: runRecord,
isWaitingForNetworkRecovery: recoveryState.isWaitingForNetworkRecovery
) {
return recoveryState.currentRun
}
case .reviewEventsFailed(let failedRun):
guard activeEventSubscriptionID == failedRun.subscriptionID,
- recoveryState.shouldConsumeEvent(from: failedRun.run)
+ recoveryState.shouldConsumeEvent(from: failedRun.run)
else {
continue
}
@@ -650,72 +631,67 @@ extension CodexReviewStore {
case .none:
continue
case .restartSettling:
- appendRecoveryProgress(networkRecoveryRestoredMessage, to: job)
+ appendRecoveryProgress(networkRecoveryRestoredMessage, to: runRecord)
}
case .networkRecoverySettled(let recoveryGeneration):
- guard recoveryState.shouldRestartReviewAfterRecoverySettle(
- recoveryGeneration: recoveryGeneration
- ) else {
+ guard
+ recoveryState.shouldRestartReviewAfterRecoverySettle(
+ recoveryGeneration: recoveryGeneration
+ )
+ else {
continue
}
switch try await restartReviewAfterNetworkRestore(
- job: job,
+ runRecord: runRecord,
startRequest: startRequest,
inputs: inputs,
- recoveryToken: recoveryState.recoveryToken
+ preparedRestartToken: recoveryState.preparedRestartToken
) {
case .continueWaiting:
recoveryState.markWaitingForNetworkRecovery()
continue
case .finished:
- reviewRecoveryWaitingJobIDs.remove(job.id)
+ runtimeState.clearWaitingForNetworkRecovery(runRecord.id)
return recoveryState.currentRun
case .recovered(let recoveredAttempt):
let recoveredRun = recoveredAttempt.run
- applyBackendRun(recoveredRun, to: job)
+ applyBackendRun(recoveredRun, to: runRecord)
recoveryState.markRecovered(with: recoveredRun)
- reviewRecoveryWaitingJobIDs.remove(job.id)
+ runtimeState.clearWaitingForNetworkRecovery(runRecord.id)
activeEventSubscriptionID = await inputs.subscribe(to: recoveredAttempt)
}
case .networkOutageConfirmed:
guard recoveryState.isWaitingForNetworkRecovery == false,
- job.isTerminal == false,
- job.cancellationRequested == false,
- await inputs.networkStatusTracker.currentStatus() != .satisfied
+ runRecord.isTerminal == false,
+ runRecord.cancellationRequested == false,
+ await inputs.networkStatusTracker.currentStatus() != .satisfied
else {
continue
}
recoveryState.markWaitingForNetworkRecovery()
- markReviewWaitingForNetworkRecovery(job)
- reviewRecoveryWaitingJobIDs.insert(job.id)
+ markReviewWaitingForNetworkRecovery(runRecord)
+ runtimeState.markWaitingForNetworkRecovery(runRecord.id)
activeEventSubscriptionID = nil
await inputs.cancelActiveEventSubscription()
- let recoveryToken = try await backend.beginReviewRecovery(
- recoveryState.currentRun,
- reason: recoveryState.recoveryReason
- )
- recoveryState.markRecoveryToken(recoveryToken)
+ let restartToken = try await backend.prepareReviewRestart(recoveryState.currentRun)
+ recoveryState.markPreparedRestartToken(restartToken)
}
}
if Task.isCancelled {
throw CancellationError()
}
- if job.isTerminal == false {
- if completePendingCancellationIfNeeded(for: job) {
+ if runRecord.isTerminal == false {
+ if completePendingCancellationIfNeeded(for: runRecord) {
return recoveryState.currentRun
}
- completeReview(
- job,
- summary: job.core.output.summary,
- result: job.core.output.lastAgentMessage
- )
+ markReviewFailed(runRecord, message: "Review completed without a final response.")
}
return recoveryState.currentRun
}
private func handleReviewEventsFinished(
- job: CodexReviewJob,
+ runRecord: ReviewRunRecord,
isWaitingForNetworkRecovery: Bool
) throws -> Bool {
if Task.isCancelled {
@@ -723,18 +699,14 @@ extension CodexReviewStore {
}
if isWaitingForNetworkRecovery {
- return job.isTerminal || completePendingCancellationIfNeeded(for: job)
+ return runRecord.isTerminal || completePendingCancellationIfNeeded(for: runRecord)
}
- if job.isTerminal == false {
- if completePendingCancellationIfNeeded(for: job) {
+ if runRecord.isTerminal == false {
+ if completePendingCancellationIfNeeded(for: runRecord) {
return true
}
- completeReview(
- job,
- summary: job.core.output.summary,
- result: job.core.output.lastAgentMessage
- )
+ markReviewFailed(runRecord, message: "Review completed without a final response.")
}
return true
}
@@ -749,78 +721,78 @@ extension CodexReviewStore {
}
private func restartReviewAfterNetworkRestore(
- job: CodexReviewJob,
+ runRecord: ReviewRunRecord,
startRequest: CodexReviewBackendModel.Review.Start,
inputs: ReviewWorkerInputs,
- recoveryToken: CodexReviewBackendModel.Review.RecoveryToken?
+ preparedRestartToken: CodexReviewBackendModel.Review.RestartToken?
) async throws -> NetworkRestoreRestartResult {
- if job.isTerminal || completePendingCancellationIfNeeded(for: job) {
+ if runRecord.isTerminal || completePendingCancellationIfNeeded(for: runRecord) {
return .finished
}
if Task.isCancelled {
throw CancellationError()
}
- if job.isTerminal || completePendingCancellationIfNeeded(for: job) {
+ if runRecord.isTerminal || completePendingCancellationIfNeeded(for: runRecord) {
return .finished
}
guard await inputs.networkStatusTracker.currentStatus() == .satisfied else {
return .continueWaiting
}
- guard let recoveryToken else {
+ guard let preparedRestartToken else {
return .continueWaiting
}
- let recoveredAttempt = try await backend.resumeReviewRecovery(
- recoveryToken,
+ let recoveredAttempt = try await backend.restartPreparedReview(
+ preparedRestartToken,
request: startRequest
)
let recoveredRun = recoveredAttempt.run
- if try await stopRecoveredRunIfJobShouldNotResume(recoveredRun, job: job) {
+ if try await stopRecoveredRunIfReviewShouldNotResume(recoveredRun, runRecord: runRecord) {
return .finished
}
return .recovered(recoveredAttempt)
}
- private func stopRecoveredRunIfJobShouldNotResume(
+ private func stopRecoveredRunIfReviewShouldNotResume(
_ recoveredRun: CodexReviewBackendModel.Review.Run,
- job: CodexReviewJob
+ runRecord: ReviewRunRecord
) async throws -> Bool {
if Task.isCancelled {
try? await backend.interruptReview(
recoveredRun,
- reason: .init(message: job.core.lifecycle.cancellation?.message ?? "Cancellation requested.")
+ reason: .init(message: runRecord.core.lifecycle.cancellation?.message ?? "Cancellation requested.")
)
await backend.cleanupReview(recoveredRun)
throw CancellationError()
}
- if job.isTerminal {
- if job.core.lifecycle.status == .cancelled {
+ if runRecord.isTerminal {
+ if runRecord.core.lifecycle.status == .cancelled {
try? await backend.interruptReview(
recoveredRun,
- reason: .init(message: job.core.lifecycle.cancellation?.message ?? "Cancellation requested.")
+ reason: .init(message: runRecord.core.lifecycle.cancellation?.message ?? "Cancellation requested.")
)
}
await backend.cleanupReview(recoveredRun)
return true
}
- guard job.cancellationRequested else {
+ guard runRecord.cancellationRequested else {
return false
}
- let cancellation = job.core.lifecycle.cancellation ?? .system()
+ let cancellation = runRecord.core.lifecycle.cancellation ?? .system()
do {
try await backend.interruptReview(recoveredRun, reason: .init(message: cancellation.message))
try completeCancellationLocally(
- jobID: job.id,
- sessionID: job.sessionID,
+ runID: runRecord.id,
+ sessionID: runRecord.sessionID,
cancellation: cancellation
)
} catch {
await backend.cleanupReview(recoveredRun)
try? recordCancellationFailure(
- jobID: job.id,
- sessionID: job.sessionID,
+ runID: runRecord.id,
+ sessionID: runRecord.sessionID,
message: error.localizedDescription
)
throw error
@@ -831,90 +803,36 @@ extension CodexReviewStore {
private func handleReviewEvent(
_ event: CodexReviewBackendModel.Review.Event,
- job: CodexReviewJob,
+ runRecord: ReviewRunRecord,
currentRun: CodexReviewBackendModel.Review.Run
) -> CodexReviewBackendModel.Review.Run {
- guard job.isTerminal == false else {
+ guard runRecord.isTerminal == false else {
return currentRun
}
- if event.completesReviewRun, completePendingCancellationIfNeeded(for: job) {
+ if event.completesReviewRun, completePendingCancellationIfNeeded(for: runRecord) {
writeDiagnosticsIfNeeded()
return currentRun
}
var updatedRun = currentRun
switch event {
- case .domainEvents(let events, let legacyProjectionSuppressionCount):
- job.applyDirectTimelineEvents(
- events,
- legacyProjectionSuppressionCount: legacyProjectionSuppressionCount,
- at: clock.now()
- )
- case .suppressNextLegacyTimelineProjection:
- job.suppressNextLegacyTimelineProjection()
- case .suppressNextTerminalFailureLogTimelineProjection:
- job.suppressNextTerminalFailureLogTimelineProjection()
case .started(let turnID, let reviewThreadID, let model):
- job.core.run.turnID = turnID
- job.core.run.reviewThreadID = reviewThreadID ?? job.core.run.reviewThreadID
- job.core.run.model = model ?? job.core.run.model
+ runRecord.core.run.turnID = turnID
+ runRecord.core.run.reviewThreadID = reviewThreadID ?? runRecord.core.run.reviewThreadID
+ runRecord.core.run.model = model ?? runRecord.core.run.model
updatedRun.turnID = turnID
updatedRun.reviewThreadID = reviewThreadID ?? updatedRun.reviewThreadID
updatedRun.model = model ?? updatedRun.model
- activeRuns[job.id] = updatedRun
- job.core.output.summary = "Review started."
- job.timeline.apply(.runStarted(
- turnID: .init(rawValue: turnID),
- reviewThreadID: (reviewThreadID ?? job.core.run.reviewThreadID).map(ReviewThread.ID.init(rawValue:)),
- model: model ?? job.core.run.model
- ), at: clock.now())
- case .message(let text):
- job.core.output.lastAgentMessage = text
- job.core.output.summary = text
- job.appendLogEntry(.init(kind: .agentMessage, text: text, timestamp: clock.now()))
- case .messageDelta(let text, let itemID):
- guard let updatedMessage = job.appendAgentMessageDelta(itemID: itemID, delta: text) else {
- job.discardPendingLegacyTimelineProjectionSuppression()
- return updatedRun
- }
- job.core.output.lastAgentMessage = updatedMessage
- job.core.output.summary = updatedMessage
- job.appendLogEntry(.init(
- kind: .agentMessage,
- groupID: itemID,
- text: text,
- timestamp: clock.now()
- ))
- case .log(let text):
- job.appendLogEntry(.init(kind: .progress, text: text, timestamp: clock.now()))
- case .logEntry(let kind, let text, let groupID, let replacesGroup, let metadata):
- if kind == .agentMessage {
- if let groupID, replacesGroup {
- job.noteAgentMessageSnapshot(
- itemID: groupID,
- text: text,
- isCompleted: shouldCompleteAgentMessageReplacement(metadata: metadata)
- )
- }
- job.core.output.lastAgentMessage = text
- job.core.output.summary = text
- }
- job.appendLogEntry(.init(
- kind: kind,
- groupID: groupID,
- replacesGroup: replacesGroup,
- text: text,
- metadata: metadata,
- timestamp: clock.now()
- ))
- case .completed(let summary, let result):
- completeReview(job, summary: summary, result: result)
+ runtimeState.setActiveRun(updatedRun, for: runRecord.id)
+ runRecord.core.lifecycleMessage = "Review started."
+ case .completed(let completion):
+ completeReview(runRecord, finalReview: completion.finalReview)
case .failed(let message):
- markReviewFailed(job, message: message)
+ markReviewFailed(runRecord, message: message)
case .cancelled(let message):
- let cancellation = job.core.lifecycle.cancellation ?? .system(message: message)
+ let cancellation = runRecord.core.lifecycle.cancellation ?? .system(message: message)
try? completeCancellationLocally(
- jobID: job.id,
- sessionID: job.sessionID,
+ runID: runRecord.id,
+ sessionID: runRecord.sessionID,
cancellation: cancellation
)
}
@@ -922,181 +840,70 @@ extension CodexReviewStore {
return updatedRun
}
- private func completePendingCancellationIfNeeded(for job: CodexReviewJob) -> Bool {
- guard job.cancellationRequested else {
+ private func completePendingCancellationIfNeeded(for runRecord: ReviewRunRecord) -> Bool {
+ guard runRecord.cancellationRequested else {
return false
}
- let cancellation = job.core.lifecycle.cancellation ?? .system()
+ let cancellation = runRecord.core.lifecycle.cancellation ?? .system()
try? completeCancellationLocally(
- jobID: job.id,
- sessionID: job.sessionID,
+ runID: runRecord.id,
+ sessionID: runRecord.sessionID,
cancellation: cancellation
)
return true
}
- private func completeReview(
- _ job: CodexReviewJob,
- summary: String,
- result: String?
- ) {
- guard job.isTerminal == false else {
+ private func completeReview(_ runRecord: ReviewRunRecord, finalReview: String?) {
+ guard runRecord.isTerminal == false else {
+ return
+ }
+ guard let finalReview = finalReview?.nilIfEmpty else {
+ markReviewFailed(runRecord, message: "Review completed without a final response.")
return
}
let endedAt = clock.now()
- let previousAgentMessage = job.core.output.lastAgentMessage?.nilIfEmpty
- let resultText = result?.nilIfEmpty
- let finalReviewText = resultText ?? previousAgentMessage
- job.closeActiveCommandLogEntries(status: "completed", completedAt: endedAt)
- job.core.lifecycle.status = .succeeded
- job.core.lifecycle.endedAt = endedAt
- job.core.output.summary = summary
- job.core.output.lastAgentMessage = finalReviewText ?? summary
- job.core.output.hasFinalReview = finalReviewText != nil
- job.core.output.reviewResult = ParsedReviewResult.parse(finalReviewText: finalReviewText)
- if let result = resultText,
- result != previousAgentMessage {
- job.appendLogEntry(.init(kind: .agentMessage, text: result, timestamp: endedAt))
- }
- job.timeline.apply(.reviewCompleted(summary: summary, result: finalReviewText), at: endedAt)
- job.applyReviewLogLimit()
+ runRecord.core.lifecycle.status = .succeeded
+ runRecord.core.lifecycle.endedAt = endedAt
+ runRecord.core.lifecycleMessage = "Review completed."
+ runRecord.core.finalReview = finalReview
writeDiagnosticsIfNeeded()
}
- private func waitForReviewTerminal(jobID: String, timeout: Duration?) async {
- guard let job = job(id: jobID),
- job.isTerminal == false
+ private func waitForReviewTerminal(runID: String, timeout: Duration?) async {
+ guard let runRecord = reviewRun(id: runID),
+ runRecord.isTerminal == false
else {
return
}
_ = await ReviewObservationAwaiter.waitUntilTerminal(
- timeline: job.timeline,
+ run: runRecord,
timeout: timeout
)
}
- private func nextJobSortOrder(inWorkspace cwd: String) -> Double {
- (jobs(inWorkspace: cwd).map(\.sortOrder).max() ?? -1) + 1
- }
-
- private func nextWorkspaceSortOrder() -> Double {
- (workspaces.map(\.sortOrder).max() ?? -1) + 1
- }
-}
-
-private struct ReviewReadLogGroupKey: Hashable {
- var kind: ReviewLogEntry.Kind
- var groupID: String
-}
-
-private func projectedLogsForReviewRead(_ entries: [ReviewLogEntry]) -> [ReviewLogEntry] {
- var projected: [ReviewLogEntry] = []
- var indexByGroup: [ReviewReadLogGroupKey: Int] = [:]
-
- for entry in entries {
- guard let key = reviewReadLogGroupKey(for: entry) else {
- projected.append(entry)
- continue
- }
-
- if let index = indexByGroup[key] {
- guard entry.replacesGroup || shouldAppendReviewReadLogDelta(for: entry.kind) else {
- projected.append(entry)
- continue
- }
-
- let existing = projected[index]
- let text = entry.replacesGroup ? entry.text : existing.text + entry.text
- let metadata = entry.replacesGroup ? entry.metadata : entry.metadata ?? existing.metadata
- projected[index] = ReviewLogEntry(
- id: entry.id,
- kind: entry.kind,
- groupID: entry.groupID,
- replacesGroup: false,
- text: text,
- metadata: metadata,
- timestamp: entry.timestamp
- )
- continue
- }
-
- if entry.replacesGroup || shouldAppendReviewReadLogDelta(for: entry.kind) {
- indexByGroup[key] = projected.count
- }
- projected.append(ReviewLogEntry(
- id: entry.id,
- kind: entry.kind,
- groupID: entry.groupID,
- replacesGroup: false,
- text: entry.text,
- metadata: entry.metadata,
- timestamp: entry.timestamp
- ))
- }
-
- return projected
-}
-
-private func reviewReadLogGroupKey(for entry: ReviewLogEntry) -> ReviewReadLogGroupKey? {
- guard let groupID = entry.groupID?.nilIfEmpty else {
- return nil
- }
-
- return ReviewReadLogGroupKey(kind: entry.kind, groupID: groupID)
-}
-
-private func shouldAppendReviewReadLogDelta(for kind: ReviewLogEntry.Kind) -> Bool {
- switch kind {
- case .agentMessage,
- .command,
- .commandOutput,
- .plan,
- .reasoning,
- .reasoningSummary,
- .rawReasoning,
- .contextCompaction:
- return true
- case .todoList,
- .toolCall,
- .diagnostic,
- .error,
- .progress,
- .event:
- return false
+ private func nextReviewRunSortOrder() -> Double {
+ (reviewRuns.map(\.sortOrder).max() ?? -1) + 1
}
}
-private func shouldCompleteAgentMessageReplacement(metadata: ReviewLogEntry.Metadata?) -> Bool {
- guard let status = metadata?.status?.nilIfEmpty else {
- return true
- }
- return ReviewItemPhase.normalized(status).isTerminal
-}
-
private extension CodexReviewBackendModel.Review.Event {
var completesReviewRun: Bool {
switch self {
case .completed, .failed, .cancelled:
true
- case .domainEvents,
- .suppressNextLegacyTimelineProjection,
- .suppressNextTerminalFailureLogTimelineProjection,
- .started,
- .message,
- .messageDelta,
- .log,
- .logEntry:
+ case .started:
false
}
}
}
-private extension CodexReviewJob {
+private extension ReviewRunRecord {
var backendRun: CodexReviewBackendModel.Review.Run? {
guard let threadID = core.run.threadID else {
return nil
}
return .init(
+ attemptID: core.run.attemptID ?? "attempt-1",
threadID: threadID,
turnID: core.run.turnID,
reviewThreadID: core.run.reviewThreadID,
@@ -1104,32 +911,6 @@ private extension CodexReviewJob {
)
}
- func appendAgentMessageDelta(itemID: String, delta: String) -> String? {
- guard completedAgentMessageItemIDs.contains(itemID) == false else {
- return nil
- }
- let updated = (agentMessagesByItemID[itemID] ?? "") + delta
- agentMessagesByItemID[itemID] = updated
- return updated
- }
-
- func noteAgentMessageSnapshot(itemID: String, text: String, isCompleted: Bool) {
- agentMessagesByItemID[itemID] = text
- if isCompleted {
- completedAgentMessageItemIDs.insert(itemID)
- } else {
- completedAgentMessageItemIDs.remove(itemID)
- }
- }
-
- func resetReviewAttemptOutputForRecovery() {
- core.output.lastAgentMessage = nil
- core.output.hasFinalReview = false
- core.output.reviewResult = nil
- agentMessagesByItemID.removeAll(keepingCapacity: true)
- completedAgentMessageItemIDs.removeAll(keepingCapacity: true)
- replaceLogEntries(logEntries.filter { $0.kind != .agentMessage }, resetDirectTimeline: true)
- }
}
private struct ReviewWorkerReviewEvent: Sendable {
@@ -1186,12 +967,10 @@ private enum ReviewNetworkSnapshotEffect {
private struct ReviewNetworkRecoveryLoopState {
var currentRun: CodexReviewBackendModel.Review.Run
private(set) var isWaitingForNetworkRecovery = false
- private(set) var recoveryToken: CodexReviewBackendModel.Review.RecoveryToken?
+ private(set) var preparedRestartToken: CodexReviewBackendModel.Review.RestartToken?
private var isSettlingForNetworkRecovery = false
private var recoverySettleGeneration: Int?
private var pendingOutageStreamFailure: ReviewWorkerEventStreamFailure?
- let recoveryReason = CodexReviewBackendModel.CancellationReason(message: networkRecoveryUnavailableMessage)
-
init(currentRun: CodexReviewBackendModel.Review.Run) {
self.currentRun = currentRun
}
@@ -1203,14 +982,14 @@ private struct ReviewNetworkRecoveryLoopState {
pendingOutageStreamFailure = nil
}
- mutating func markRecoveryToken(_ token: CodexReviewBackendModel.Review.RecoveryToken) {
- recoveryToken = token
+ mutating func markPreparedRestartToken(_ token: CodexReviewBackendModel.Review.RestartToken) {
+ preparedRestartToken = token
}
mutating func markRecovered(with run: CodexReviewBackendModel.Review.Run) {
currentRun = run
isWaitingForNetworkRecovery = false
- recoveryToken = nil
+ preparedRestartToken = nil
isSettlingForNetworkRecovery = false
recoverySettleGeneration = nil
pendingOutageStreamFailure = nil
@@ -1224,7 +1003,7 @@ private struct ReviewNetworkRecoveryLoopState {
_ snapshot: CodexReviewNetworkSnapshot
) -> ReviewWorkerEventStreamFailure? {
guard snapshot.status == .satisfied,
- isWaitingForNetworkRecovery == false
+ isWaitingForNetworkRecovery == false
else {
return nil
}
@@ -1242,7 +1021,7 @@ private struct ReviewNetworkRecoveryLoopState {
isWaitingForNetworkRecovery
&& isSettlingForNetworkRecovery
&& recoverySettleGeneration == recoveryGeneration
- && recoveryToken != nil
+ && preparedRestartToken != nil
}
func shouldConsumeEvent(from run: CodexReviewBackendModel.Review.Run) -> Bool {
@@ -1325,7 +1104,8 @@ private actor ReviewWorkerInputQueue {
return
}
if let waiterID = waiters.keys.first,
- let waiter = waiters.removeValue(forKey: waiterID) {
+ let waiter = waiters.removeValue(forKey: waiterID)
+ {
waiter.resume(returning: .input(input))
} else {
bufferedInputs.append(input)
@@ -1451,27 +1231,31 @@ private actor ReviewWorkerEventSource {
subscriptionID: Int
) async {
guard activeSubscriptionID == subscriptionID,
- eventTasks[subscriptionID] != nil
+ eventTasks[subscriptionID] != nil
else {
return
}
- await queue.send(.reviewEvent(.init(
- subscriptionID: subscriptionID,
- subscriptionRun: run,
- event: event
- )))
+ await queue.send(
+ .reviewEvent(
+ .init(
+ subscriptionID: subscriptionID,
+ subscriptionRun: run,
+ event: event
+ )))
}
private func yieldEventsFinished(run: CodexReviewBackendModel.Review.Run, subscriptionID: Int) async {
guard activeSubscriptionID == subscriptionID,
- eventTasks.removeValue(forKey: subscriptionID) != nil
+ eventTasks.removeValue(forKey: subscriptionID) != nil
else {
return
}
- await queue.send(.reviewEventsFinished(.init(
- subscriptionID: subscriptionID,
- run: run
- )))
+ await queue.send(
+ .reviewEventsFinished(
+ .init(
+ subscriptionID: subscriptionID,
+ run: run
+ )))
}
private func yieldEventsFailed(
@@ -1485,11 +1269,13 @@ private actor ReviewWorkerEventSource {
guard activeSubscriptionID == subscriptionID else {
return
}
- await queue.send(.reviewEventsFailed(.init(
- subscriptionID: subscriptionID,
- run: run,
- failure: error is CancellationError ? .cancelled : .failed(error.localizedDescription)
- )))
+ await queue.send(
+ .reviewEventsFailed(
+ .init(
+ subscriptionID: subscriptionID,
+ run: run,
+ failure: error is CancellationError ? .cancelled : .failed(error.localizedDescription)
+ )))
}
}
diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreRuntimeState.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreRuntimeState.swift
new file mode 100644
index 00000000..ae64d2cb
--- /dev/null
+++ b/Sources/CodexReviewKit/Store/CodexReviewStoreRuntimeState.swift
@@ -0,0 +1,162 @@
+import Foundation
+
+package struct CodexReviewRuntimeRunState: Sendable {
+ package let activeRun: CodexReviewBackendModel.Review.Run?
+ package let hasActiveWorker: Bool
+ package let hasDetachedWorker: Bool
+ package let isWaitingForNetworkRecovery: Bool
+}
+
+@MainActor
+final class CodexReviewStoreRuntimeState {
+ typealias BackendReviewRun = CodexReviewBackendModel.Review.Run
+
+ private var activeRuns: [String: BackendReviewRun] = [:]
+ private var reviewRecoveryWaitingRunIDs: Set = []
+ private var startingRunIDs: Set = []
+ private var startupCancellations: [String: ReviewCancellation] = [:]
+ private var reviewWorkerTasks: [String: Task] = [:]
+ private var runtimeStopDetachedReviewWorkerTasks: [String: Task] = [:]
+
+ func runState(for runID: String) -> CodexReviewRuntimeRunState {
+ CodexReviewRuntimeRunState(
+ activeRun: activeRuns[runID],
+ hasActiveWorker: reviewWorkerTasks[runID] != nil,
+ hasDetachedWorker: runtimeStopDetachedReviewWorkerTasks[runID] != nil,
+ isWaitingForNetworkRecovery: reviewRecoveryWaitingRunIDs.contains(runID)
+ )
+ }
+
+ func activeRun(for runID: String) -> BackendReviewRun? {
+ activeRuns[runID]
+ }
+
+ func setActiveRun(_ run: BackendReviewRun, for runID: String) {
+ activeRuns[runID] = run
+ }
+
+ func removeActiveRun(for runID: String) {
+ activeRuns.removeValue(forKey: runID)
+ }
+
+ func recoveryWaitingRuns() -> [BackendReviewRun] {
+ reviewRecoveryWaitingRunIDs
+ .sorted()
+ .compactMap { activeRuns[$0] }
+ }
+
+ func isWaitingForNetworkRecovery(_ runID: String) -> Bool {
+ reviewRecoveryWaitingRunIDs.contains(runID)
+ }
+
+ func markWaitingForNetworkRecovery(_ runID: String) {
+ reviewRecoveryWaitingRunIDs.insert(runID)
+ }
+
+ func clearWaitingForNetworkRecovery(_ runID: String) {
+ reviewRecoveryWaitingRunIDs.remove(runID)
+ }
+
+ func markStarting(_ runID: String) {
+ startingRunIDs.insert(runID)
+ }
+
+ func clearStarting(_ runID: String) {
+ startingRunIDs.remove(runID)
+ }
+
+ func isStarting(_ runID: String) -> Bool {
+ startingRunIDs.contains(runID)
+ }
+
+ func setStartupCancellation(_ cancellation: ReviewCancellation, for runID: String) {
+ startupCancellations[runID] = cancellation
+ }
+
+ func takeStartupCancellation(for runID: String) -> ReviewCancellation? {
+ startupCancellations.removeValue(forKey: runID)
+ }
+
+ func setActiveWorker(_ task: Task, for runID: String) {
+ reviewWorkerTasks[runID] = task
+ }
+
+ func cancelActiveWorker(for runID: String) {
+ reviewWorkerTasks[runID]?.cancel()
+ }
+
+ func removeActiveWorker(for runID: String) {
+ reviewWorkerTasks.removeValue(forKey: runID)
+ }
+
+ func awaitActiveWorker(for runID: String) async {
+ let task = reviewWorkerTasks[runID]
+ await task?.value
+ }
+
+ func cancelAndDetachActiveWorkerForRuntimeStop(runID: String) {
+ if let task = reviewWorkerTasks.removeValue(forKey: runID) {
+ task.cancel()
+ runtimeStopDetachedReviewWorkerTasks[runID] = task
+ }
+ }
+
+ func removeDetachedWorker(for runID: String) {
+ runtimeStopDetachedReviewWorkerTasks.removeValue(forKey: runID)
+ }
+
+ func activeWorkerTasks() -> [Task] {
+ Array(reviewWorkerTasks.values)
+ }
+
+ func detachedWorkerTasks() -> [Task] {
+ Array(runtimeStopDetachedReviewWorkerTasks.values)
+ }
+
+ func allWorkerTasks() -> [Task] {
+ activeWorkerTasks() + detachedWorkerTasks()
+ }
+
+ func clearRuntimeStopState(for runID: String) {
+ removeActiveRun(for: runID)
+ clearWaitingForNetworkRecovery(runID)
+ clearStarting(runID)
+ _ = takeStartupCancellation(for: runID)
+ }
+
+ func clearReviewRunState(for runID: String) {
+ removeActiveRun(for: runID)
+ clearWaitingForNetworkRecovery(runID)
+ }
+
+ func cancelAllWorkers() {
+ for task in allWorkerTasks() {
+ task.cancel()
+ }
+ }
+
+ func cancelAndDrainAllWorkersForTesting() async {
+ let tasks = allWorkerTasks()
+ for task in tasks {
+ task.cancel()
+ }
+ for task in tasks {
+ await task.value
+ }
+ }
+
+ func clearForTesting() {
+ reviewWorkerTasks.removeAll(keepingCapacity: false)
+ runtimeStopDetachedReviewWorkerTasks.removeAll(keepingCapacity: false)
+ startingRunIDs.removeAll(keepingCapacity: false)
+ startupCancellations.removeAll(keepingCapacity: false)
+ activeRuns.removeAll(keepingCapacity: false)
+ reviewRecoveryWaitingRunIDs.removeAll(keepingCapacity: false)
+ }
+}
+
+extension CodexReviewStore {
+ package func runtimeReviewRunState(runID: String) -> CodexReviewRuntimeRunState {
+ runtimeState.runState(for: runID)
+ }
+}
diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift
new file mode 100644
index 00000000..bac56de4
--- /dev/null
+++ b/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift
@@ -0,0 +1,57 @@
+import Foundation
+
+extension CodexReviewStore {
+ nonisolated(unsafe) private static var requestCancellationDelayForTestingStorage: TimeInterval = 0
+ nonisolated(unsafe) package static var requestCancellationDelay: TimeInterval {
+ get { requestCancellationDelayForTestingStorage }
+ set { requestCancellationDelayForTestingStorage = max(0, newValue) }
+ }
+
+ @_spi(Testing)
+ public static var requestCancellationDelayForTesting: TimeInterval {
+ get { requestCancellationDelay }
+ set { requestCancellationDelay = newValue }
+ }
+
+ package func loadForTesting(
+ serverState: CodexReviewServerState,
+ authPhase: CodexReviewAuthModel.Phase = .signedOut,
+ account: CodexReviewAccount? = nil,
+ persistedAccounts: [CodexReviewAccount]? = nil,
+ serverURL: URL? = nil,
+ reviewRuns: [ReviewRunRecord] = [],
+ settingsSnapshot: CodexReviewSettings.Snapshot? = nil
+ ) {
+ precondition(
+ backend.isActive == false,
+ "loadForTesting must be called before the embedded server starts."
+ )
+ self.serverState = serverState
+ self.auth.updatePhase(authPhase)
+ let resolvedPersistedAccounts = persistedAccounts ?? account.map { [$0] } ?? []
+ self.auth.applyPersistedAccountStates(
+ resolvedPersistedAccounts.map(savedAccountPayload(from:))
+ )
+ if let account,
+ resolvedPersistedAccounts.contains(where: { $0.accountKey == account.accountKey })
+ {
+ self.auth.selectPersistedAccount(account.id)
+ } else {
+ self.auth.updateCurrentAccount(account)
+ }
+ self.serverURL = serverURL
+ for (index, runRecord) in reviewRuns.enumerated() {
+ runRecord.sortOrder = Double(reviewRuns.count - index - 1)
+ }
+ self.reviewRuns = Set(reviewRuns)
+ if let settingsSnapshot {
+ settings.loadForTesting(snapshot: settingsSnapshot)
+ }
+ writeDiagnosticsIfNeeded()
+ }
+
+ package func cancelAndDrainReviewWorkersForTesting() async {
+ await runtimeState.cancelAndDrainAllWorkersForTesting()
+ runtimeState.clearForTesting()
+ }
+}
diff --git a/Sources/CodexReview/Store/PreviewCodexReviewStoreBackend.swift b/Sources/CodexReviewKit/Store/PreviewCodexReviewStoreBackend.swift
similarity index 94%
rename from Sources/CodexReview/Store/PreviewCodexReviewStoreBackend.swift
rename to Sources/CodexReviewKit/Store/PreviewCodexReviewStoreBackend.swift
index d75426d9..b5f89460 100644
--- a/Sources/CodexReview/Store/PreviewCodexReviewStoreBackend.swift
+++ b/Sources/CodexReviewKit/Store/PreviewCodexReviewStoreBackend.swift
@@ -139,15 +139,12 @@ package class PreviewCodexReviewStoreBackend: CodexReviewStoreBackend {
package func interruptReview(_: CodexReviewBackendModel.Review.Run, reason _: CodexReviewBackendModel.CancellationReason) async throws {}
- package func beginReviewRecovery(
- _: CodexReviewBackendModel.Review.Run,
- reason _: CodexReviewBackendModel.CancellationReason
- ) async throws -> CodexReviewBackendModel.Review.RecoveryToken {
+ package func prepareReviewRestart(_: CodexReviewBackendModel.Review.Run) async throws -> CodexReviewBackendModel.Review.RestartToken {
throw CodexReviewAPI.Error.io(Self.previewUnavailableMessage)
}
- package func resumeReviewRecovery(
- _: CodexReviewBackendModel.Review.RecoveryToken,
+ package func restartPreparedReview(
+ _: CodexReviewBackendModel.Review.RestartToken,
request _: CodexReviewBackendModel.Review.Start
) async throws -> BackendReviewAttempt {
throw CodexReviewAPI.Error.io(Self.previewUnavailableMessage)
diff --git a/Sources/CodexReview/Support/String+NilIfEmpty.swift b/Sources/CodexReviewKit/Support/String+NilIfEmpty.swift
similarity index 100%
rename from Sources/CodexReview/Support/String+NilIfEmpty.swift
rename to Sources/CodexReviewKit/Support/String+NilIfEmpty.swift
diff --git a/Sources/CodexReviewMCPAdapter/ReviewMCPProjection.swift b/Sources/CodexReviewMCPAdapter/ReviewMCPProjection.swift
deleted file mode 100644
index 8c4af6e6..00000000
--- a/Sources/CodexReviewMCPAdapter/ReviewMCPProjection.swift
+++ /dev/null
@@ -1,196 +0,0 @@
-import Foundation
-import CodexReviewDomain
-
-public struct ReviewMCPProjection: Sendable, Equatable {
- public struct Item: Sendable, Equatable {
- public var id: ReviewTimelineItem.ID
- public var kind: ReviewItemKind
- public var family: ReviewItemFamily
- public var phase: ReviewItemPhase
- public var isActive: Bool
- public var content: Content
- public var createdAt: Date
- public var updatedAt: Date
- public var startedAt: Date?
- public var completedAt: Date?
- public var durationMs: Int?
-
- @MainActor
- public init(item: ReviewTimelineItem, isActive: Bool) {
- self.id = item.id
- self.kind = item.kind
- self.family = item.family
- self.phase = item.phase
- self.isActive = isActive
- self.content = Content(item.content)
- self.createdAt = item.createdAt
- self.updatedAt = item.updatedAt
- self.startedAt = item.startedAt
- self.completedAt = item.completedAt
- self.durationMs = item.durationMs
- }
- }
-
- public enum Content: Sendable, Equatable {
- case approval(Approval)
- case command(Command)
- case contextCompaction(ContextCompaction)
- case diagnostic(Diagnostic)
- case fileChange(FileChange)
- case message(Message)
- case plan(Plan)
- case reasoning(Reasoning)
- case search(Search)
- case toolCall(ToolCall)
- case unknown(Unknown)
-
- public var type: String {
- switch self {
- case .approval:
- "approval"
- case .command:
- "command"
- case .contextCompaction:
- "contextCompaction"
- case .diagnostic:
- "diagnostic"
- case .fileChange:
- "fileChange"
- case .message:
- "message"
- case .plan:
- "plan"
- case .reasoning:
- "reasoning"
- case .search:
- "search"
- case .toolCall:
- "toolCall"
- case .unknown:
- "unknown"
- }
- }
-
- public init(_ content: ReviewTimelineItem.Content) {
- switch content {
- case .approval(let approval):
- self = .approval(.init(title: approval.title, detail: approval.detail))
- case .command(let command):
- self = .command(.init(
- command: command.command,
- cwd: command.cwd,
- output: command.output,
- exitCode: command.exitCode
- ))
- case .contextCompaction(let contextCompaction):
- self = .contextCompaction(.init(title: contextCompaction.title))
- case .diagnostic(let diagnostic):
- self = .diagnostic(.init(message: diagnostic.message))
- case .fileChange(let fileChange):
- self = .fileChange(.init(title: fileChange.title, output: fileChange.output))
- case .message(let message):
- self = .message(.init(text: message.text))
- case .plan(let plan):
- self = .plan(.init(markdown: plan.markdown))
- case .reasoning(let reasoning):
- self = .reasoning(.init(text: reasoning.text, style: reasoning.style))
- case .search(let search):
- self = .search(.init(query: search.query, result: search.result))
- case .toolCall(let toolCall):
- self = .toolCall(.init(
- namespace: toolCall.namespace,
- server: toolCall.server,
- tool: toolCall.tool,
- arguments: toolCall.arguments,
- progress: toolCall.progress,
- result: toolCall.result,
- error: toolCall.error
- ))
- case .unknown(let unknown):
- self = .unknown(.init(title: unknown.title, detail: unknown.detail))
- }
- }
- }
-
- public struct Approval: Sendable, Equatable {
- public var title: String
- public var detail: String?
- }
-
- public struct Command: Sendable, Equatable {
- public var command: String
- public var cwd: String?
- public var output: String
- public var exitCode: Int?
- }
-
- public struct ContextCompaction: Sendable, Equatable {
- public var title: String
- }
-
- public struct Diagnostic: Sendable, Equatable {
- public var message: String
- }
-
- public struct FileChange: Sendable, Equatable {
- public var title: String
- public var output: String
- }
-
- public struct Message: Sendable, Equatable {
- public var text: String
- }
-
- public struct Plan: Sendable, Equatable {
- public var markdown: String
- }
-
- public struct Reasoning: Sendable, Equatable {
- public var text: String
- public var style: ReviewTimelineItem.Reasoning.Style
- }
-
- public struct Search: Sendable, Equatable {
- public var query: String
- public var result: String?
- }
-
- public struct ToolCall: Sendable, Equatable {
- public var namespace: String?
- public var server: String?
- public var tool: String?
- public var arguments: String?
- public var progress: String?
- public var result: String?
- public var error: String?
- }
-
- public struct Unknown: Sendable, Equatable {
- public var title: String
- public var detail: String?
- }
-
- public var timelineRevision: ReviewTimeline.Revision
- public var orderedItemIDs: [ReviewTimelineItem.ID]
- public var activeItemIDs: [ReviewTimelineItem.ID]
- public var activeItemCount: Int
- public var latestActivityID: ReviewTimelineItem.ID?
- public var terminalSummary: String?
- public var terminalResult: String?
- public var items: [Item]
-
- @MainActor
- public init(timeline: ReviewTimeline) {
- let activeItemIDs = timeline.orderedItemIDs.filter { timeline.activeItemIDs.contains($0) }
- self.timelineRevision = timeline.revision
- self.orderedItemIDs = timeline.orderedItemIDs
- self.activeItemIDs = activeItemIDs
- self.activeItemCount = activeItemIDs.count
- self.latestActivityID = timeline.latestActivity
- self.terminalSummary = timeline.terminalSummary
- self.terminalResult = timeline.terminalResult
- self.items = timeline.items.map { item in
- Item(item: item, isActive: timeline.activeItemIDs.contains(item.id))
- }
- }
-}
diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPHTTPServer.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPHTTPServer.swift
index 484614ac..529e0071 100644
--- a/Sources/CodexReviewMCPServer/CodexReviewMCPHTTPServer.swift
+++ b/Sources/CodexReviewMCPServer/CodexReviewMCPHTTPServer.swift
@@ -2,6 +2,7 @@ import Darwin
import Foundation
import MCP
import OSLog
+import Synchronization
@preconcurrency import NIOCore
@preconcurrency import NIOHTTP1
@preconcurrency import NIOPosix
@@ -516,22 +517,22 @@ package actor CodexReviewMCPHTTPServer {
}
private final class ActiveRequestCompletion: @unchecked Sendable {
- private let lock = NSLock()
+ private let didFinish = Mutex(false)
private let onFinish: @Sendable () -> Void
- private var didFinish = false
init(onFinish: @escaping @Sendable () -> Void) {
self.onFinish = onFinish
}
func finish() {
- lock.lock()
- if didFinish {
- lock.unlock()
- return
+ let shouldFinish = didFinish.withLock { didFinish in
+ if didFinish {
+ return false
+ }
+ didFinish = true
+ return true
}
- didFinish = true
- lock.unlock()
+ guard shouldFinish else { return }
onFinish()
}
}
diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPHelpResources.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPHelpResources.swift
new file mode 100644
index 00000000..e3a353c0
--- /dev/null
+++ b/Sources/CodexReviewMCPServer/CodexReviewMCPHelpResources.swift
@@ -0,0 +1,109 @@
+import MCP
+
+struct HelpResource: Sendable {
+ var uri: String
+ var name: String
+ var description: String
+ var content: String
+
+ var resource: Resource {
+ Resource(
+ name: name,
+ uri: uri,
+ description: description,
+ mimeType: "text/markdown"
+ )
+ }
+}
+
+let helpResources: [HelpResource] = [
+ .init(
+ uri: "codex-review://help/overview",
+ name: "Codex Review MCP Overview",
+ description: "Overview of the Codex review MCP tools.",
+ content: """
+ # Codex Review MCP
+
+ Use `review_start` to run a review, `review_await` to continue waiting for long-running runs, then `review_read`, `review_list`, or `review_cancel` to inspect or control review runs.
+ """
+ ),
+ .init(
+ uri: "codex-review://help/tools/review_start",
+ name: "review_start",
+ description: "Input shape for starting a Codex review.",
+ content: """
+ # review_start
+
+ Required arguments: `cwd` and `target`.
+
+ Supported target types: `uncommittedChanges`, `baseBranch`, `commit`, and `custom`.
+ """
+ ),
+ .init(
+ uri: "codex-review://help/tools/review_await",
+ name: "review_await",
+ description: "Wait for a running Codex review run.",
+ content: """
+ # review_await
+
+ Required argument: `runId`.
+
+ Use this after `review_start` returns a running run. The tool waits for the run to finish and returns the final review when available.
+ """
+ ),
+ .init(
+ uri: "codex-review://help/targets/uncommittedChanges",
+ name: "Target: uncommittedChanges",
+ description: "Review uncommitted workspace changes.",
+ content: """
+ # Target: uncommittedChanges
+
+ `{"type":"uncommittedChanges"}`
+ """
+ ),
+ .init(
+ uri: "codex-review://help/targets/baseBranch",
+ name: "Target: baseBranch",
+ description: "Review changes against a base branch.",
+ content: """
+ # Target: baseBranch
+
+ `{"type":"baseBranch","branch":"main"}`
+ """
+ ),
+ .init(
+ uri: "codex-review://help/targets/commit",
+ name: "Target: commit",
+ description: "Review a specific commit.",
+ content: """
+ # Target: commit
+
+ `{"type":"commit","sha":"abc1234","title":"Optional title"}`
+ """
+ ),
+ .init(
+ uri: "codex-review://help/targets/custom",
+ name: "Target: custom",
+ description: "Run a review with custom instructions.",
+ content: """
+ # Target: custom
+
+ `{"type":"custom","instructions":"Free-form review instructions"}`
+ """
+ ),
+]
+
+let helpResourceTemplates: [Resource.Template] = [
+ .init(
+ uriTemplate: "codex-review://help/tools/{tool}",
+ name: "Codex Review tool help",
+ description: "Help for a Codex Review MCP tool.",
+ mimeType: "text/markdown"
+ ),
+ .init(
+ uriTemplate: "codex-review://help/targets/{target}",
+ name: "Codex Review target help",
+ description: "Help for a Codex Review target shape.",
+ mimeType: "text/markdown"
+ ),
+]
diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPProtocolServer.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPProtocolServer.swift
index c8499d66..14d5e61a 100644
--- a/Sources/CodexReviewMCPServer/CodexReviewMCPProtocolServer.swift
+++ b/Sources/CodexReviewMCPServer/CodexReviewMCPProtocolServer.swift
@@ -1,7 +1,6 @@
import Foundation
import MCP
-import CodexReview
-import CodexReviewMCPAdapter
+import CodexReviewKit
package actor MCPClientSessionState {
private var clientInfo: Client.Info?
@@ -76,7 +75,7 @@ package func makeMCPProtocolServer(
useBoundedReviewStart: useBoundedReviewStart
)
let response = try await adapter.handle(request)
- return try toolResult(tool: tool, response: response)
+ return try toolResult(response: response)
} catch {
return .init(
content: [.text(text: error.localizedDescription, annotations: nil, _meta: nil)],
@@ -101,1059 +100,3 @@ package func makeMCPProtocolServer(
return server
}
-
-private func schema(for tool: CodexReviewMCP.Tool.Name) -> Value {
- switch tool {
- case .reviewStart:
- .object([
- "type": .string("object"),
- "properties": .object([
- "sessionID": .object(["type": .string("string")]),
- "cwd": .object(["type": .string("string")]),
- "target": .object([
- "type": .string("object"),
- "properties": .object([
- "type": .object(["type": .string("string")]),
- "branch": .object(["type": .string("string")]),
- "sha": .object(["type": .string("string")]),
- "title": .object(["type": .string("string")]),
- "instructions": .object(["type": .string("string")]),
- ]),
- "required": .array([.string("type")]),
- ]),
- ]),
- "required": .array([.string("cwd"), .string("target")]),
- ])
- case .reviewAwait:
- .object([
- "type": .string("object"),
- "properties": .object([
- "sessionID": .object(["type": .string("string")]),
- "jobID": .object(["type": .string("string")]),
- "jobId": .object(["type": .string("string")]),
- ]),
- "anyOf": .array([
- .object(["required": .array([.string("jobId")])]),
- .object(["required": .array([.string("jobID")])]),
- ]),
- ])
- case .reviewRead:
- .object([
- "type": .string("object"),
- "properties": .object([
- "sessionID": .object(["type": .string("string")]),
- "jobID": .object(["type": .string("string")]),
- "jobId": .object(["type": .string("string")]),
- "logOffset": .object([
- "type": .string("integer"),
- "minimum": .int(0),
- ]),
- "logLimit": .object([
- "type": .string("integer"),
- "minimum": .int(1),
- "maximum": .int(CodexReviewAPI.Log.PageRequest.maxLimit),
- ]),
- "logFilter": .object([
- "type": .string("string"),
- "enum": .array([
- .string(CodexReviewAPI.Log.Filter.defaultSetting.rawValue),
- .string(CodexReviewAPI.Log.Filter.all.rawValue),
- ]),
- ]),
- ]),
- ])
- case .reviewList:
- .object([
- "type": .string("object"),
- "properties": .object([
- "sessionID": .object(["type": .string("string")]),
- "cwd": .object(["type": .string("string")]),
- "statuses": .object(["type": .string("array")]),
- "limit": .object(["type": .string("integer")]),
- ]),
- ])
- case .reviewCancel:
- .object([
- "type": .string("object"),
- "properties": .object([
- "sessionID": .object(["type": .string("string")]),
- "jobID": .object(["type": .string("string")]),
- "jobId": .object(["type": .string("string")]),
- "cwd": .object(["type": .string("string")]),
- "statuses": .object(["type": .string("array")]),
- "reason": .object(["type": .string("string")]),
- ]),
- ])
- }
-}
-
-private func toolRequest(
- tool: CodexReviewMCP.Tool.Name,
- arguments: [String: Value],
- defaultSessionID: String?,
- boundedReviewWaitDuration: Duration,
- useBoundedReviewStart: Bool
-) throws -> CodexReviewMCP.Tool.Request {
- switch tool {
- case .reviewStart:
- let cwd = try requiredString("cwd", in: arguments)
- let target = try reviewTarget(from: requiredObject("target", in: arguments))
- return .reviewStart(
- sessionID: sessionID(in: arguments, defaultSessionID: defaultSessionID) ?? "default",
- request: .init(cwd: cwd, target: target),
- waitTimeout: useBoundedReviewStart ? boundedReviewWaitDuration : nil
- )
- case .reviewAwait:
- return .reviewAwait(
- sessionID: sessionID(in: arguments, defaultSessionID: defaultSessionID),
- jobID: try requiredJobID(in: arguments),
- waitTimeout: boundedReviewWaitDuration
- )
- case .reviewRead:
- return .reviewRead(
- sessionID: sessionID(in: arguments, defaultSessionID: defaultSessionID),
- jobID: try requiredJobID(in: arguments),
- logFilter: try reviewLogFilter(in: arguments),
- logPage: try reviewLogPageRequest(in: arguments)
- )
- case .reviewList:
- return .reviewList(
- sessionID: sessionID(in: arguments, defaultSessionID: defaultSessionID),
- cwd: arguments["cwd"]?.stringValue,
- statuses: try statuses(from: arguments["statuses"]),
- limit: arguments["limit"]?.intValue
- )
- case .reviewCancel:
- let jobID = optionalJobID(in: arguments)
- let sessionID = sessionID(
- in: arguments,
- defaultSessionID: defaultSessionID,
- fallback: jobID == nil ? "default" : nil
- )
- return .reviewCancel(
- sessionID: sessionID,
- selector: .init(
- jobID: jobID,
- cwd: arguments["cwd"]?.stringValue,
- statuses: try statuses(from: arguments["statuses"])
- ),
- reason: .mcpClient(message: arguments["reason"]?.stringValue ?? "Cancellation requested.")
- )
- }
-}
-
-private func sessionID(
- in arguments: [String: Value],
- defaultSessionID: String?,
- fallback: String? = nil
-) -> String? {
- defaultSessionID ?? arguments["sessionID"]?.stringValue ?? fallback
-}
-
-private func toolResult(tool: CodexReviewMCP.Tool.Name, response: CodexReviewMCP.Tool.Response) throws -> CallTool.Result {
- let value: Value
- let text: String
- let isError: Bool
- switch response {
- case .reviewRead(let result, let timeline, let timelinePage):
- value = tool == .reviewRead
- ? result.structuredContentForRead(timeline: timeline, timelinePage: timelinePage)
- : result.structuredContentForStartOrAwait(timeline: timeline)
- text = tool == .reviewRead
- ? result.textContentForRead()
- : result.textContentForStartOrAwait()
- isError = result.core.lifecycle.status == .failed
- case .reviewList(let result):
- value = result.structuredContent()
- text = "Listed \(result.items.count) review job(s)."
- isError = false
- case .reviewCancel(let result):
- value = result.structuredContent()
- text = result.textContent()
- isError = false
- }
- return try .init(
- content: [.text(text: text, annotations: nil, _meta: nil)],
- structuredContent: value,
- isError: isError
- )
-}
-
-private func optionalJobID(in arguments: [String: Value]) -> String? {
- arguments["jobID"]?.stringValue?.nilIfEmpty ?? arguments["jobId"]?.stringValue?.nilIfEmpty
-}
-
-private func requiredJobID(in arguments: [String: Value]) throws -> String {
- guard let jobID = optionalJobID(in: arguments) else {
- throw MCPProtocolServerError.missingArgument("jobID/jobId")
- }
- return jobID
-}
-
-private func reviewLogFilter(in arguments: [String: Value]) throws -> CodexReviewAPI.Log.Filter {
- guard let rawValue = arguments["logFilter"]?.stringValue?.nilIfEmpty else {
- return .defaultSetting
- }
- guard let filter = CodexReviewAPI.Log.Filter(rawValue: rawValue) else {
- throw MCPProtocolServerError.invalidArgument(
- "Unsupported logFilter: \(rawValue). Use `default` or `all`."
- )
- }
- return filter
-}
-
-private func reviewLogPageRequest(in arguments: [String: Value]) throws -> CodexReviewAPI.Log.PageRequest {
- let offset: Int?
- if let value = arguments["logOffset"] {
- guard let parsed = value.intValue else {
- throw MCPProtocolServerError.invalidArgument("logOffset must be an integer.")
- }
- guard parsed >= 0 else {
- throw MCPProtocolServerError.invalidArgument("logOffset must be greater than or equal to 0.")
- }
- offset = parsed
- } else {
- offset = nil
- }
-
- let limit: Int
- if let value = arguments["logLimit"] {
- guard let parsed = value.intValue else {
- throw MCPProtocolServerError.invalidArgument("logLimit must be an integer.")
- }
- guard (1...CodexReviewAPI.Log.PageRequest.maxLimit).contains(parsed) else {
- throw MCPProtocolServerError.invalidArgument(
- "logLimit must be between 1 and \(CodexReviewAPI.Log.PageRequest.maxLimit)."
- )
- }
- limit = parsed
- } else {
- limit = CodexReviewAPI.Log.PageRequest.defaultLimit
- }
-
- return CodexReviewAPI.Log.PageRequest(offset: offset, limit: limit)
-}
-
-private func reviewTarget(from object: [String: Value]) throws -> CodexReviewAPI.Target {
- switch object["type"]?.stringValue {
- case "uncommittedChanges":
- .uncommittedChanges
- case "baseBranch":
- .baseBranch(try requiredString("branch", in: object))
- case "commit":
- .commit(
- sha: try requiredString("sha", in: object),
- title: object["title"]?.stringValue
- )
- case "custom":
- .custom(instructions: try requiredString("instructions", in: object))
- case let type:
- throw MCPProtocolServerError.invalidArgument("Unsupported review target: \(type ?? "").")
- }
-}
-
-private struct HelpResource: Sendable {
- var uri: String
- var name: String
- var description: String
- var content: String
-
- var resource: Resource {
- Resource(
- name: name,
- uri: uri,
- description: description,
- mimeType: "text/markdown"
- )
- }
-}
-
-private let helpResources: [HelpResource] = [
- .init(
- uri: "codex-review://help/overview",
- name: "Codex Review MCP Overview",
- description: "Overview of the Codex review MCP tools.",
- content: """
- # Codex Review MCP
-
- Use `review_start` to run a review, `review_await` to continue waiting for long-running jobs, then `review_read`, `review_list`, or `review_cancel` to inspect or control review jobs.
- """
- ),
- .init(
- uri: "codex-review://help/tools/review_start",
- name: "review_start",
- description: "Input shape for starting a Codex review.",
- content: """
- # review_start
-
- Required arguments: `cwd` and `target`.
-
- Supported target types: `uncommittedChanges`, `baseBranch`, `commit`, and `custom`.
- """
- ),
- .init(
- uri: "codex-review://help/tools/review_await",
- name: "review_await",
- description: "Wait for a running Codex review job.",
- content: """
- # review_await
-
- Required argument: `jobId`.
-
- Use this after `review_start` returns a running job. The tool waits for the job to finish and returns the final review when available.
- """
- ),
- .init(
- uri: "codex-review://help/targets/uncommittedChanges",
- name: "Target: uncommittedChanges",
- description: "Review uncommitted workspace changes.",
- content: """
- # Target: uncommittedChanges
-
- `{"type":"uncommittedChanges"}`
- """
- ),
- .init(
- uri: "codex-review://help/targets/baseBranch",
- name: "Target: baseBranch",
- description: "Review changes against a base branch.",
- content: """
- # Target: baseBranch
-
- `{"type":"baseBranch","branch":"main"}`
- """
- ),
- .init(
- uri: "codex-review://help/targets/commit",
- name: "Target: commit",
- description: "Review a specific commit.",
- content: """
- # Target: commit
-
- `{"type":"commit","sha":"abc1234","title":"Optional title"}`
- """
- ),
- .init(
- uri: "codex-review://help/targets/custom",
- name: "Target: custom",
- description: "Run a review with custom instructions.",
- content: """
- # Target: custom
-
- `{"type":"custom","instructions":"Free-form review instructions"}`
- """
- ),
-]
-
-private let helpResourceTemplates: [Resource.Template] = [
- .init(
- uriTemplate: "codex-review://help/tools/{tool}",
- name: "Codex Review tool help",
- description: "Help for a Codex Review MCP tool.",
- mimeType: "text/markdown"
- ),
- .init(
- uriTemplate: "codex-review://help/targets/{target}",
- name: "Codex Review target help",
- description: "Help for a Codex Review target shape.",
- mimeType: "text/markdown"
- ),
-]
-
-private func statuses(from value: Value?) throws -> [ReviewJobState]? {
- guard let value else {
- return nil
- }
- guard let array = value.arrayValue else {
- throw MCPProtocolServerError.invalidArgument("statuses must be an array.")
- }
- return try array.map { item in
- guard let raw = item.stringValue, let status = ReviewJobState(rawValue: raw) else {
- throw MCPProtocolServerError.invalidArgument("Invalid review status.")
- }
- return status
- }
-}
-
-private func requiredObject(
- _ key: String,
- in arguments: [String: Value]
-) throws -> [String: Value] {
- guard let object = arguments[key]?.objectValue else {
- throw MCPProtocolServerError.missingArgument(key)
- }
- return object
-}
-
-private func requiredString(
- _ key: String,
- in arguments: [String: Value]
-) throws -> String {
- guard let value = arguments[key]?.stringValue, value.isEmpty == false else {
- throw MCPProtocolServerError.missingArgument(key)
- }
- return value
-}
-
-private extension CodexReviewAPI.Read.Result {
- func textContent() -> String {
- core.reviewText.nilIfEmpty ?? core.lifecycle.status.rawValue
- }
-
- func textContentForStartOrAwait() -> String {
- if core.lifecycle.status.isTerminal {
- return textContent()
- }
-
- var status = "Review \(core.lifecycle.status.rawValue)"
- if let elapsedSeconds {
- status += " for \(elapsedSeconds)s"
- }
- return "\(status). jobId: \(jobID). Call `review_await` with this jobId to continue waiting."
- }
-
- func textContentForRead() -> String {
- if core.lifecycle.status.isTerminal {
- return textContent()
- }
-
- var parts: [String] = []
- var status = "Review \(core.lifecycle.status.rawValue)"
- if let elapsedSeconds {
- status += " for \(elapsedSeconds)s"
- }
- parts.append(status + ".")
- parts.append("Returned logs \(logsPage.rangeDescription) of \(logsPage.total).")
- if let latest = logs.last(where: { $0.text.nilIfEmpty != nil }) {
- parts.append("Latest: \(Self.truncatedLatestText(latest.text))")
- }
- return parts.joined(separator: " ")
- }
-
- private static func truncatedLatestText(_ text: String) -> String {
- let normalized = text
- .replacingOccurrences(of: "\r\n", with: "\n")
- .replacingOccurrences(of: "\r", with: "\n")
- .split(separator: "\n", omittingEmptySubsequences: false)
- .joined(separator: " ")
- let limit = 300
- guard normalized.count > limit else {
- return normalized
- }
- let index = normalized.index(normalized.startIndex, offsetBy: limit)
- return String(normalized[.. Value {
- structuredContent(
- includeDetails: false,
- includeNextAction: core.lifecycle.status.isTerminal == false,
- timeline: timeline
- )
- }
-
- func structuredContentForRead(
- timeline: ReviewMCPProjection,
- timelinePage: CodexReviewAPI.Log.PageRequest?
- ) -> Value {
- structuredContent(
- includeDetails: true,
- includeNextAction: false,
- timeline: timeline,
- timelinePage: timelinePage ?? .default
- )
- }
-
- func structuredContent(
- includeDetails: Bool,
- includeNextAction: Bool,
- timeline: ReviewMCPProjection,
- timelinePage: CodexReviewAPI.Log.PageRequest? = nil
- ) -> Value {
- var object: [String: Value] = [
- "jobId": .string(jobID),
- "run": core.run.structuredContent(),
- "lifecycle": core.lifecycle.structuredContent(
- elapsedSeconds: elapsedSeconds,
- cancellable: cancellable
- ),
- "output": core.output.structuredContent(review: core.reviewText),
- ]
- if includeDetails {
- object["logs"] = .array(logs.map { $0.structuredContent() })
- object["logsPage"] = logsPage.structuredContent()
- object["rawLogText"] = .string(rawLogText)
- }
- object["timeline"] = includeDetails
- ? timeline.structuredContent(pageRequest: timelinePage ?? .default)
- : timeline.structuredContent()
- if includeNextAction {
- object["nextAction"] = .object([
- "tool": .string(CodexReviewMCP.Tool.Name.reviewAwait.rawValue),
- "jobId": .string(jobID),
- ])
- }
- return .object(object)
- }
-}
-
-private extension ReviewMCPProjection {
- func structuredContent() -> Value {
- var truncatedFields: [String] = []
- var object: [String: Value] = [
- "revision": timelineRevision.rawValue.structuredRevisionValue(),
- "orderedItemIds": .array(orderedItemIDs.map { .string($0.rawValue) }),
- "activeItemIds": .array(activeItemIDs.map { .string($0.rawValue) }),
- "activeItemCount": .int(activeItemCount),
- "latestActivityId": latestActivityID.map { .string($0.rawValue) } ?? .null,
- "terminalSummary": boundedTimelineString(
- terminalSummary,
- field: "terminalSummary",
- truncatedFields: &truncatedFields
- ),
- "terminalResult": boundedTimelineString(
- terminalResult,
- field: "terminalResult",
- truncatedFields: &truncatedFields
- ),
- ]
- let page = TimelineItemPage.unreturned(total: items.count)
- object["items"] = .array([])
- object["itemsPage"] = page.structuredContent()
- object["truncatedFields"] = .array(truncatedFields.map(Value.string))
- return .object(object)
- }
-
- func structuredContent(pageRequest: CodexReviewAPI.Log.PageRequest) -> Value {
- var truncatedFields: [String] = []
- var object: [String: Value] = [
- "revision": timelineRevision.rawValue.structuredRevisionValue(),
- "orderedItemIds": .array(orderedItemIDs.map { .string($0.rawValue) }),
- "activeItemIds": .array(activeItemIDs.map { .string($0.rawValue) }),
- "activeItemCount": .int(activeItemCount),
- "latestActivityId": latestActivityID.map { .string($0.rawValue) } ?? .null,
- "terminalSummary": boundedTimelineString(
- terminalSummary,
- field: "terminalSummary",
- truncatedFields: &truncatedFields
- ),
- "terminalResult": boundedTimelineString(
- terminalResult,
- field: "terminalResult",
- truncatedFields: &truncatedFields
- ),
- ]
- let page = TimelineItemPage(pageRequest: pageRequest, total: items.count)
- object["items"] = .array(items[page.range].map { $0.structuredContent() })
- object["itemsPage"] = page.structuredContent()
- object["truncatedFields"] = .array(truncatedFields.map(Value.string))
- return .object(object)
- }
-}
-
-private extension ReviewMCPProjection.Item {
- func structuredContent() -> Value {
- .object([
- "id": .string(id.rawValue),
- "kind": .string(kind.rawValue),
- "family": .string(family.rawValue),
- "phase": .string(phase.rawValue),
- "isActive": .bool(isActive),
- "content": content.structuredContent(),
- "createdAt": .string(createdAt.ISO8601Format()),
- "updatedAt": .string(updatedAt.ISO8601Format()),
- "startedAt": startedAt.map { .string($0.ISO8601Format()) } ?? .null,
- "completedAt": completedAt.map { .string($0.ISO8601Format()) } ?? .null,
- "durationMs": durationMs.map(Value.int) ?? .null,
- ])
- }
-}
-
-private extension ReviewMCPProjection.Content {
- func structuredContent() -> Value {
- switch self {
- case .approval(let approval):
- var truncatedFields: [String] = []
- return .object([
- "type": .string("approval"),
- "title": boundedTimelineString(
- approval.title,
- field: "title",
- truncatedFields: &truncatedFields
- ),
- "detail": boundedTimelineString(
- approval.detail,
- field: "detail",
- truncatedFields: &truncatedFields
- ),
- "truncatedFields": .array(truncatedFields.map(Value.string)),
- ])
- case .command(let command):
- var truncatedFields: [String] = []
- return .object([
- "type": .string("command"),
- "command": boundedTimelineString(
- command.command,
- field: "command",
- truncatedFields: &truncatedFields
- ),
- "cwd": boundedTimelineString(
- command.cwd,
- field: "cwd",
- truncatedFields: &truncatedFields
- ),
- "output": boundedTimelineString(
- command.output,
- field: "output",
- truncatedFields: &truncatedFields
- ),
- "exitCode": command.exitCode.map(Value.int) ?? .null,
- "truncatedFields": .array(truncatedFields.map(Value.string)),
- ])
- case .contextCompaction(let contextCompaction):
- var truncatedFields: [String] = []
- return .object([
- "type": .string("contextCompaction"),
- "title": boundedTimelineString(
- contextCompaction.title,
- field: "title",
- truncatedFields: &truncatedFields
- ),
- "truncatedFields": .array(truncatedFields.map(Value.string)),
- ])
- case .diagnostic(let diagnostic):
- var truncatedFields: [String] = []
- return .object([
- "type": .string("diagnostic"),
- "message": boundedTimelineString(
- diagnostic.message,
- field: "message",
- truncatedFields: &truncatedFields
- ),
- "truncatedFields": .array(truncatedFields.map(Value.string)),
- ])
- case .fileChange(let fileChange):
- var truncatedFields: [String] = []
- return .object([
- "type": .string("fileChange"),
- "title": boundedTimelineString(
- fileChange.title,
- field: "title",
- truncatedFields: &truncatedFields
- ),
- "output": boundedTimelineString(
- fileChange.output,
- field: "output",
- truncatedFields: &truncatedFields
- ),
- "truncatedFields": .array(truncatedFields.map(Value.string)),
- ])
- case .message(let message):
- var truncatedFields: [String] = []
- return .object([
- "type": .string("message"),
- "text": boundedTimelineString(
- message.text,
- field: "text",
- truncatedFields: &truncatedFields
- ),
- "truncatedFields": .array(truncatedFields.map(Value.string)),
- ])
- case .plan(let plan):
- var truncatedFields: [String] = []
- return .object([
- "type": .string("plan"),
- "markdown": boundedTimelineString(
- plan.markdown,
- field: "markdown",
- truncatedFields: &truncatedFields
- ),
- "truncatedFields": .array(truncatedFields.map(Value.string)),
- ])
- case .reasoning(let reasoning):
- var truncatedFields: [String] = []
- return .object([
- "type": .string("reasoning"),
- "text": boundedTimelineString(
- reasoning.text,
- field: "text",
- truncatedFields: &truncatedFields
- ),
- "style": .string(reasoning.style.rawValue),
- "truncatedFields": .array(truncatedFields.map(Value.string)),
- ])
- case .search(let search):
- var truncatedFields: [String] = []
- return .object([
- "type": .string("search"),
- "query": boundedTimelineString(
- search.query,
- field: "query",
- truncatedFields: &truncatedFields
- ),
- "result": boundedTimelineString(
- search.result,
- field: "result",
- truncatedFields: &truncatedFields
- ),
- "truncatedFields": .array(truncatedFields.map(Value.string)),
- ])
- case .toolCall(let toolCall):
- var truncatedFields: [String] = []
- return .object([
- "type": .string("toolCall"),
- "namespace": boundedTimelineString(
- toolCall.namespace,
- field: "namespace",
- truncatedFields: &truncatedFields
- ),
- "server": boundedTimelineString(
- toolCall.server,
- field: "server",
- truncatedFields: &truncatedFields
- ),
- "tool": boundedTimelineString(
- toolCall.tool,
- field: "tool",
- truncatedFields: &truncatedFields
- ),
- "arguments": boundedTimelineString(
- toolCall.arguments,
- field: "arguments",
- truncatedFields: &truncatedFields
- ),
- "progress": boundedTimelineString(
- toolCall.progress,
- field: "progress",
- truncatedFields: &truncatedFields
- ),
- "result": boundedTimelineString(
- toolCall.result,
- field: "result",
- truncatedFields: &truncatedFields
- ),
- "error": boundedTimelineString(
- toolCall.error,
- field: "error",
- truncatedFields: &truncatedFields
- ),
- "truncatedFields": .array(truncatedFields.map(Value.string)),
- ])
- case .unknown(let unknown):
- var truncatedFields: [String] = []
- return .object([
- "type": .string("unknown"),
- "title": boundedTimelineString(
- unknown.title,
- field: "title",
- truncatedFields: &truncatedFields
- ),
- "detail": boundedTimelineString(
- unknown.detail,
- field: "detail",
- truncatedFields: &truncatedFields
- ),
- "truncatedFields": .array(truncatedFields.map(Value.string)),
- ])
- }
- }
-}
-
-private struct TimelineItemPage {
- var total: Int
- var offset: Int
- var limit: Int
- var returned: Int
- var hasMoreBefore: Bool
- var hasMoreAfter: Bool
- var previousOffset: Int?
- var nextOffset: Int?
-
- var range: Range {
- offset.. TimelineItemPage {
- TimelineItemPage(
- total: total,
- offset: 0,
- limit: 0,
- returned: 0,
- hasMoreBefore: false,
- hasMoreAfter: total > 0,
- previousOffset: nil,
- nextOffset: total > 0 ? 0 : nil
- )
- }
-
- init(pageRequest: CodexReviewAPI.Log.PageRequest, total: Int) {
- let limit = pageRequest.limit
- let offset = min(pageRequest.offset ?? max(0, total - limit), total)
- let returned = min(limit, max(0, total - offset))
- self.total = total
- self.offset = offset
- self.limit = limit
- self.returned = returned
- self.hasMoreBefore = offset > 0
- self.hasMoreAfter = offset + returned < total
- self.previousOffset = hasMoreBefore ? max(0, offset - limit) : nil
- self.nextOffset = hasMoreAfter ? offset + returned : nil
- }
-
- func structuredContent() -> Value {
- .object([
- "total": .int(total),
- "offset": .int(offset),
- "limit": .int(limit),
- "returned": .int(returned),
- "hasMoreBefore": .bool(hasMoreBefore),
- "hasMoreAfter": .bool(hasMoreAfter),
- "previousOffset": previousOffset.map(Value.int) ?? .null,
- "nextOffset": nextOffset.map(Value.int) ?? .null,
- ])
- }
-}
-
-private func boundedTimelineString(
- _ value: String?,
- field: String,
- truncatedFields: inout [String]
-) -> Value {
- guard let value else {
- return .null
- }
- return boundedTimelineString(value, field: field, truncatedFields: &truncatedFields)
-}
-
-private func boundedTimelineString(
- _ value: String,
- field: String,
- truncatedFields: inout [String]
-) -> Value {
- let bounded = value.boundedTimelineString()
- if bounded.wasTruncated {
- truncatedFields.append(field)
- }
- return .string(bounded.value)
-}
-
-private extension String {
- func boundedTimelineString() -> (value: String, wasTruncated: Bool) {
- let limit = 4096
- guard count > limit else {
- return (self, false)
- }
- let end = index(startIndex, offsetBy: limit)
- return (String(self[.. Value {
- if self <= UInt64(Int.max) {
- return .int(Int(self))
- }
- return .string(String(self))
- }
-}
-
-private extension CodexReviewAPI.Log.Page {
- var rangeDescription: String {
- guard returned > 0 else {
- return "0"
- }
- return "\(offset + 1)-\(offset + returned)"
- }
-
- func structuredContent() -> Value {
- .object([
- "total": .int(total),
- "offset": .int(offset),
- "limit": .int(limit),
- "returned": .int(returned),
- "hasMoreBefore": .bool(hasMoreBefore),
- "hasMoreAfter": .bool(hasMoreAfter),
- "previousOffset": previousOffset.map(Value.int) ?? .null,
- "nextOffset": nextOffset.map(Value.int) ?? .null,
- ])
- }
-}
-
-private extension CodexReviewAPI.Job.ListItem {
- func structuredContent() -> Value {
- .object([
- "jobId": .string(jobID),
- "cwd": .string(cwd),
- "targetSummary": .string(targetSummary),
- "run": core.run.structuredContent(),
- "lifecycle": core.lifecycle.structuredContent(
- elapsedSeconds: elapsedSeconds,
- cancellable: cancellable
- ),
- "output": core.output.structuredContent(review: core.reviewText),
- ])
- }
-}
-
-private extension CodexReviewAPI.List.Result {
- func structuredContent() -> Value {
- .object([
- "items": .array(items.map { $0.structuredContent() }),
- ])
- }
-}
-
-private extension CodexReviewAPI.Cancel.Outcome {
- func textContent() -> String {
- if cancelled {
- core.lifecycle.cancellation?.message ?? "Review cancelled."
- } else {
- "Review was already finished."
- }
- }
-
- func structuredContent() -> Value {
- .object([
- "jobId": .string(jobID),
- "cancelled": .bool(cancelled),
- "run": core.run.structuredContent(),
- "lifecycle": core.lifecycle.structuredContent(
- elapsedSeconds: nil,
- cancellable: false
- ),
- "output": core.output.structuredContent(review: core.reviewText),
- ])
- }
-}
-
-private extension ReviewLogEntry {
- func structuredContent() -> Value {
- var object: [String: Value] = [
- "id": .string(id.uuidString),
- "kind": .string(kind.rawValue),
- "replacesGroup": .bool(replacesGroup),
- "text": .string(text),
- "timestamp": .string(timestamp.ISO8601Format()),
- ]
- object["groupId"] = groupID.map(Value.string) ?? .null
- return .object(object)
- }
-}
-
-private extension ReviewJobCore.Run {
- func structuredContent() -> Value {
- .object([
- "reviewThreadId": reviewThreadID.map(Value.string) ?? .null,
- "threadId": threadID.map(Value.string) ?? .null,
- "turnId": turnID.map(Value.string) ?? .null,
- "model": model.map(Value.string) ?? .null,
- ])
- }
-}
-
-private extension ReviewJobCore.Lifecycle {
- func structuredContent(
- elapsedSeconds: Int?,
- cancellable: Bool
- ) -> Value {
- .object([
- "status": .string(status.rawValue),
- "exitCode": exitCode.map(Value.int) ?? .null,
- "startedAt": startedAt.map { .string($0.ISO8601Format()) } ?? .null,
- "endedAt": endedAt.map { .string($0.ISO8601Format()) } ?? .null,
- "elapsedSeconds": elapsedSeconds.map(Value.int) ?? .null,
- "cancellable": .bool(cancellable),
- "cancellation": cancellation.map { $0.structuredContent() } ?? .null,
- "errorMessage": errorMessage.map(Value.string) ?? .null,
- ])
- }
-}
-
-private extension ReviewJobCore.Output {
- func structuredContent(review: String) -> Value {
- .object([
- "summary": .string(summary),
- "review": .string(review),
- "hasFinalReview": .bool(hasFinalReview),
- "lastAgentMessage": lastAgentMessage.map(Value.string) ?? .null,
- "reviewResult": reviewResult.map { $0.structuredContent() } ?? .null,
- ])
- }
-}
-
-private extension ReviewCancellation {
- func structuredContent() -> Value {
- .object([
- "source": .string(source.rawValue),
- "message": .string(message),
- ])
- }
-}
-
-private extension ParsedReviewResult {
- func structuredContent() -> Value {
- .object([
- "state": .string(state.rawValue),
- "findingCount": findingCount.map(Value.int) ?? .null,
- "findings": .array(findings.map { $0.structuredContent() }),
- "source": .string(source.rawValue),
- "parserVersion": .int(parserVersion),
- ])
- }
-}
-
-private extension ParsedReviewResult.Finding {
- func structuredContent() -> Value {
- .object([
- "title": .string(title),
- "body": .string(body),
- "priority": priority.map(Value.int) ?? .null,
- "location": location.map { $0.structuredContent() } ?? .null,
- "rawText": .string(rawText),
- ])
- }
-}
-
-private extension ParsedReviewResult.Finding.Location {
- func structuredContent() -> Value {
- .object([
- "path": .string(path),
- "startLine": .int(startLine),
- "endLine": .int(endLine),
- ])
- }
-}
-
-private enum MCPProtocolServerError: LocalizedError {
- case missingArgument(String)
- case invalidArgument(String)
-
- var errorDescription: String? {
- switch self {
- case .missingArgument(let key):
- "Missing required argument: \(key)."
- case .invalidArgument(let message):
- message
- }
- }
-}
diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPProtocolServerError.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPProtocolServerError.swift
new file mode 100644
index 00000000..7051cd02
--- /dev/null
+++ b/Sources/CodexReviewMCPServer/CodexReviewMCPProtocolServerError.swift
@@ -0,0 +1,15 @@
+import Foundation
+
+enum MCPProtocolServerError: LocalizedError {
+ case missingArgument(String)
+ case invalidArgument(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .missingArgument(let key):
+ "Missing required argument: \(key)."
+ case .invalidArgument(let message):
+ message
+ }
+ }
+}
diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift
index cfa69de5..3b4b725a 100644
--- a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift
+++ b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift
@@ -1,114 +1,129 @@
import Foundation
-import CodexReview
-import CodexReviewMCPAdapter
+import CodexKit
+import CodexReviewKit
package enum CodexReviewMCP {
package enum Tool {}
}
+package typealias ReviewMCPLogProjectionProvider = @MainActor @Sendable (
+ CodexReviewAPI.Read.Result
+) async -> ReviewMCPLogProjection?
+
package extension CodexReviewMCP.Tool {
-enum Name: String, Codable, Equatable, Sendable, CaseIterable {
- case reviewStart = "review_start"
- case reviewAwait = "review_await"
- case reviewRead = "review_read"
- case reviewList = "review_list"
- case reviewCancel = "review_cancel"
-}
+ enum Name: String, Codable, Equatable, Sendable, CaseIterable {
+ case reviewStart = "review_start"
+ case reviewAwait = "review_await"
+ case reviewRead = "review_read"
+ case reviewList = "review_list"
+ case reviewCancel = "review_cancel"
+ }
}
-
package extension CodexReviewMCP.Tool {
-struct Descriptor: Codable, Equatable, Sendable {
- package var name: CodexReviewMCP.Tool.Name
- package var description: String
+ struct Descriptor: Codable, Equatable, Sendable {
+ package var name: CodexReviewMCP.Tool.Name
+ package var description: String
- package init(name: CodexReviewMCP.Tool.Name, description: String) {
- self.name = name
- self.description = description
+ package init(name: CodexReviewMCP.Tool.Name, description: String) {
+ self.name = name
+ self.description = description
+ }
}
}
-}
-
package extension CodexReviewMCP.Tool {
-enum Request: Equatable, Sendable {
- case reviewStart(sessionID: String, request: CodexReviewAPI.Start.Request, waitTimeout: Duration?)
- case reviewAwait(sessionID: String?, jobID: String, waitTimeout: Duration)
- case reviewRead(sessionID: String?, jobID: String, logFilter: CodexReviewAPI.Log.Filter, logPage: CodexReviewAPI.Log.PageRequest)
- case reviewList(sessionID: String?, cwd: String?, statuses: [ReviewJobState]?, limit: Int?)
- case reviewCancel(sessionID: String?, selector: CodexReviewAPI.Job.Selector, reason: ReviewCancellation)
-}
+ enum Request: Equatable, Sendable {
+ case reviewStart(sessionID: String, request: CodexReviewAPI.Start.Request, waitTimeout: Duration?)
+ case reviewAwait(sessionID: String?, runID: String, waitTimeout: Duration)
+ case reviewRead(sessionID: String?, runID: String)
+ case reviewList(sessionID: String?, cwd: String?, statuses: [ReviewRunState]?, limit: Int?)
+ case reviewCancel(sessionID: String?, selector: CodexReviewAPI.Run.Selector, reason: ReviewCancellation)
+ }
}
-
package extension CodexReviewMCP.Tool {
-enum Response: Equatable, Sendable {
- case reviewRead(
- CodexReviewAPI.Read.Result,
- timeline: ReviewMCPProjection,
- timelinePage: CodexReviewAPI.Log.PageRequest?
- )
- case reviewList(CodexReviewAPI.List.Result)
- case reviewCancel(CodexReviewAPI.Cancel.Outcome)
-}
+ internal struct ReviewSnapshot: Equatable, Sendable {
+ var result: CodexReviewAPI.Read.Result
+ var log: ReviewMCPLogProjection
+
+ init(result: CodexReviewAPI.Read.Result, log: ReviewMCPLogProjection) {
+ self.result = result
+ self.log = log
+ }
+ }
}
+package extension CodexReviewMCP.Tool {
+ internal enum Response: Equatable, Sendable {
+ case reviewStart(ReviewSnapshot)
+ case reviewAwait(ReviewSnapshot)
+ case reviewRead(ReviewSnapshot)
+ case reviewList(CodexReviewAPI.List.Result)
+ case reviewCancel(CodexReviewAPI.Cancel.Outcome)
+ }
+}
@MainActor
package final class CodexReviewMCPServer {
private let store: CodexReviewStore
+ private let logProjectionProvider: ReviewMCPLogProjectionProvider?
- package init(store: CodexReviewStore) {
+ package init(
+ store: CodexReviewStore,
+ logProjectionProvider: ReviewMCPLogProjectionProvider? = nil
+ ) {
self.store = store
+ self.logProjectionProvider = logProjectionProvider
}
package var tools: [CodexReviewMCP.Tool.Descriptor] {
[
.init(name: .reviewStart, description: "Start a Codex review."),
- .init(name: .reviewAwait, description: "Wait for a running Codex review job."),
- .init(name: .reviewRead, description: "Read a Codex review job."),
- .init(name: .reviewList, description: "List Codex review jobs."),
- .init(name: .reviewCancel, description: "Cancel a Codex review job."),
+ .init(name: .reviewAwait, description: "Wait for a running Codex review run."),
+ .init(name: .reviewRead, description: "Read a Codex review run."),
+ .init(name: .reviewList, description: "List Codex review runs."),
+ .init(name: .reviewCancel, description: "Cancel a Codex review run."),
]
}
- package func handle(_ request: CodexReviewMCP.Tool.Request) async throws -> CodexReviewMCP.Tool.Response {
+ func handle(_ request: CodexReviewMCP.Tool.Request) async throws -> CodexReviewMCP.Tool.Response {
switch request {
case .reviewStart(let sessionID, let reviewRequest, let waitTimeout):
+ let result: CodexReviewAPI.Read.Result
if let waitTimeout {
- return try reviewReadResponse(
- try await store.startReview(
- sessionID: sessionID,
- request: reviewRequest,
- waitTimeout: waitTimeout
- ),
- sessionID: sessionID
+ result = try await store.startReview(
+ sessionID: sessionID,
+ request: reviewRequest,
+ waitTimeout: waitTimeout
)
+ } else {
+ result = try await store.startReview(sessionID: sessionID, request: reviewRequest)
}
- return try reviewReadResponse(
- try await store.startReview(sessionID: sessionID, request: reviewRequest),
+ let snapshot = try await reviewSnapshot(
+ result,
sessionID: sessionID
)
- case .reviewAwait(let sessionID, let jobID, let waitTimeout):
- return try reviewReadResponse(
+ return .reviewStart(snapshot)
+ case .reviewAwait(let sessionID, let runID, let waitTimeout):
+ let snapshot = try await reviewSnapshot(
try await store.awaitReview(
sessionID: sessionID,
- jobID: jobID,
+ runID: runID,
timeout: waitTimeout
),
sessionID: sessionID
)
- case .reviewRead(let sessionID, let jobID, let logFilter, let logPage):
- return try reviewReadResponse(
+ return .reviewAwait(snapshot)
+ case .reviewRead(let sessionID, let runID):
+ let snapshot = try await reviewSnapshot(
try store.readReview(
sessionID: sessionID,
- jobID: jobID,
- logFilter: logFilter,
- logPage: logPage
+ runID: runID
),
- sessionID: sessionID,
- timelinePage: logPage
+ sessionID: sessionID
)
+ return .reviewRead(snapshot)
case .reviewList(let sessionID, let cwd, let statuses, let limit):
return .reviewList(store.listReviews(
sessionID: sessionID,
@@ -117,31 +132,26 @@ package final class CodexReviewMCPServer {
limit: limit
))
case .reviewCancel(let sessionID, let selector, let reason):
- let job = try store.resolveJob(
+ let runRecord = try store.resolveRun(
sessionID: sessionID,
selector: selector.defaultingToActiveStatusesForCancellation()
)
return .reviewCancel(try await store.cancelReview(
- jobID: job.id,
+ runID: runRecord.id,
cancellation: reason
))
}
}
- private func reviewReadResponse(
+ private func reviewSnapshot(
_ result: CodexReviewAPI.Read.Result,
- sessionID: String?,
- timelinePage: CodexReviewAPI.Log.PageRequest? = nil
- ) throws -> CodexReviewMCP.Tool.Response {
- let job = try store.resolveJob(
- sessionID: sessionID,
- selector: .init(jobID: result.jobID)
- )
- return .reviewRead(
- result,
- timeline: ReviewMCPProjection(timeline: job.timeline),
- timelinePage: timelinePage
- )
+ sessionID: String?
+ ) async throws -> CodexReviewMCP.Tool.ReviewSnapshot {
+ if let sessionID {
+ _ = try store.resolveRun(sessionID: sessionID, selector: .init(runID: result.runID))
+ }
+ let log = await logProjectionProvider?(result) ?? ReviewMCPLogProjection.unavailable(result: result)
+ return .init(result: result, log: log)
}
package func closeSession(_ sessionID: String) async {
@@ -149,15 +159,53 @@ package final class CodexReviewMCPServer {
}
package func hasActiveReviews(in sessionID: String) -> Bool {
- store.activeJobIDs(for: sessionID).isEmpty == false
+ store.activeReviewRunIDs(for: sessionID).isEmpty == false
+ }
+}
+
+package extension CodexReviewMCPServer {
+ static func chatLogProjectionProvider(
+ modelContext: CodexModelContext
+ ) -> ReviewMCPLogProjectionProvider {
+ { result in
+ guard let turnID = result.core.run.turnID?.nilIfEmpty else {
+ return nil
+ }
+ guard let threadID = (result.core.run.reviewThreadID ?? result.core.run.threadID)?.nilIfEmpty else {
+ return nil
+ }
+
+ let chat = modelContext.model(for: CodexThreadID(rawValue: threadID))
+ do {
+ try await modelContext.refresh(chat, includeTurns: true)
+ } catch {
+ return nil
+ }
+ let codexTurnID = CodexTurnID(rawValue: turnID)
+ guard chat.turn(id: codexTurnID) != nil else {
+ return nil
+ }
+ return ReviewMCPLogProjection(
+ result: result,
+ turnID: codexTurnID,
+ threadItems: chat.items(in: codexTurnID).map(\.threadItemForReviewMCP)
+ )
+ }
+ }
+}
+
+@MainActor
+private extension CodexItem {
+ var threadItemForReviewMCP: CodexThreadItem {
+ CodexThreadItem(id: itemID, kind: kind, content: content, rawPayload: rawPayload)
}
}
-private extension CodexReviewAPI.Job.Selector {
- func defaultingToActiveStatusesForCancellation() -> CodexReviewAPI.Job.Selector {
- guard jobID == nil, statuses == nil else {
+private extension CodexReviewAPI.Run.Selector {
+ func defaultingToActiveStatusesForCancellation() -> CodexReviewAPI.Run.Selector {
+ guard runID == nil, statuses == nil else {
return self
}
- return .init(jobID: jobID, cwd: cwd, statuses: [.queued, .running])
+ return .init(runID: runID, cwd: cwd, statuses: [.queued, .running])
}
}
diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolRequests.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolRequests.swift
new file mode 100644
index 00000000..f3c45428
--- /dev/null
+++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolRequests.swift
@@ -0,0 +1,128 @@
+import Foundation
+import MCP
+import CodexReviewKit
+
+func toolRequest(
+ tool: CodexReviewMCP.Tool.Name,
+ arguments: [String: Value],
+ defaultSessionID: String?,
+ boundedReviewWaitDuration: Duration,
+ useBoundedReviewStart: Bool
+) throws -> CodexReviewMCP.Tool.Request {
+ switch tool {
+ case .reviewStart:
+ let cwd = try requiredString("cwd", in: arguments)
+ let target = try reviewTarget(from: requiredObject("target", in: arguments))
+ return .reviewStart(
+ sessionID: sessionID(in: arguments, defaultSessionID: defaultSessionID) ?? "default",
+ request: .init(cwd: cwd, target: target),
+ waitTimeout: useBoundedReviewStart ? boundedReviewWaitDuration : nil
+ )
+ case .reviewAwait:
+ return .reviewAwait(
+ sessionID: sessionID(in: arguments, defaultSessionID: defaultSessionID),
+ runID: try requiredRunID(in: arguments),
+ waitTimeout: boundedReviewWaitDuration
+ )
+ case .reviewRead:
+ return .reviewRead(
+ sessionID: sessionID(in: arguments, defaultSessionID: defaultSessionID),
+ runID: try requiredRunID(in: arguments)
+ )
+ case .reviewList:
+ return .reviewList(
+ sessionID: sessionID(in: arguments, defaultSessionID: defaultSessionID),
+ cwd: arguments["cwd"]?.stringValue,
+ statuses: try statuses(from: arguments["statuses"]),
+ limit: arguments["limit"]?.intValue
+ )
+ case .reviewCancel:
+ let runID = optionalRunID(in: arguments)
+ let sessionID = sessionID(
+ in: arguments,
+ defaultSessionID: defaultSessionID,
+ fallback: runID == nil ? "default" : nil
+ )
+ return .reviewCancel(
+ sessionID: sessionID,
+ selector: .init(
+ runID: runID,
+ cwd: arguments["cwd"]?.stringValue,
+ statuses: try statuses(from: arguments["statuses"])
+ ),
+ reason: .mcpClient(message: arguments["reason"]?.stringValue ?? "Cancellation requested.")
+ )
+ }
+}
+
+func sessionID(
+ in arguments: [String: Value],
+ defaultSessionID: String?,
+ fallback: String? = nil
+) -> String? {
+ defaultSessionID ?? arguments["sessionID"]?.stringValue ?? fallback
+}
+
+func optionalRunID(in arguments: [String: Value]) -> String? {
+ arguments["runID"]?.stringValue?.nilIfEmpty ?? arguments["runId"]?.stringValue?.nilIfEmpty
+}
+
+func requiredRunID(in arguments: [String: Value]) throws -> String {
+ guard let runID = optionalRunID(in: arguments) else {
+ throw MCPProtocolServerError.missingArgument("runID/runId")
+ }
+ return runID
+}
+
+func reviewTarget(from object: [String: Value]) throws -> CodexReviewAPI.Target {
+ switch object["type"]?.stringValue {
+ case "uncommittedChanges":
+ .uncommittedChanges
+ case "baseBranch":
+ .baseBranch(try requiredString("branch", in: object))
+ case "commit":
+ .commit(
+ sha: try requiredString("sha", in: object),
+ title: object["title"]?.stringValue
+ )
+ case "custom":
+ .custom(instructions: try requiredString("instructions", in: object))
+ case let type:
+ throw MCPProtocolServerError.invalidArgument("Unsupported review target: \(type ?? "").")
+ }
+}
+
+func statuses(from value: Value?) throws -> [ReviewRunState]? {
+ guard let value else {
+ return nil
+ }
+ guard let array = value.arrayValue else {
+ throw MCPProtocolServerError.invalidArgument("statuses must be an array.")
+ }
+ return try array.map { item in
+ guard let raw = item.stringValue, let status = ReviewRunState(rawValue: raw) else {
+ throw MCPProtocolServerError.invalidArgument("Invalid review status.")
+ }
+ return status
+ }
+}
+
+func requiredObject(
+ _ key: String,
+ in arguments: [String: Value]
+) throws -> [String: Value] {
+ guard let object = arguments[key]?.objectValue else {
+ throw MCPProtocolServerError.missingArgument(key)
+ }
+ return object
+}
+
+func requiredString(
+ _ key: String,
+ in arguments: [String: Value]
+) throws -> String {
+ guard let value = arguments[key]?.stringValue, value.isEmpty == false else {
+ throw MCPProtocolServerError.missingArgument(key)
+ }
+ return value
+}
diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift
new file mode 100644
index 00000000..6b5d4d02
--- /dev/null
+++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift
@@ -0,0 +1,483 @@
+import Foundation
+import MCP
+import CodexReviewKit
+
+func toolResult(response: CodexReviewMCP.Tool.Response) throws -> CallTool.Result {
+ let value: Value
+ let text: String
+ let isError: Bool
+ switch response {
+ case .reviewStart(let snapshot),
+ .reviewAwait(let snapshot):
+ value = snapshot.result.structuredContentForStartOrAwait(log: snapshot.log)
+ text = snapshot.result.textContentForStartOrAwait(log: snapshot.log)
+ isError = snapshot.result.core.lifecycle.status == .failed
+ case .reviewRead(let snapshot):
+ value = snapshot.result.structuredContentForRead(log: snapshot.log)
+ text = snapshot.result.textContentForRead(log: snapshot.log)
+ isError = snapshot.result.core.lifecycle.status == .failed
+ case .reviewList(let result):
+ value = result.structuredContent()
+ text = "Listed \(result.items.count) review run(s)."
+ isError = false
+ case .reviewCancel(let result):
+ value = result.structuredContent()
+ text = result.textContent()
+ isError = false
+ }
+ return try .init(
+ content: [.text(text: text, annotations: nil, _meta: nil)],
+ structuredContent: value,
+ isError: isError
+ )
+}
+
+private extension CodexReviewAPI.Read.Result {
+ func textContent(log: ReviewMCPLogProjection) -> String {
+ log.finalResult?.nilIfEmpty ?? core.finalReview ?? core.displayLifecycleMessage
+ }
+
+ func textContentForStartOrAwait(log: ReviewMCPLogProjection) -> String {
+ if core.lifecycle.status.isTerminal {
+ return textContent(log: log)
+ }
+
+ var status = "Review \(core.lifecycle.status.rawValue)"
+ if let elapsedSeconds {
+ status += " for \(elapsedSeconds)s"
+ }
+ return "\(status). runId: \(runID). Call `review_await` with this runId to continue waiting."
+ }
+
+ func textContentForRead(log: ReviewMCPLogProjection) -> String {
+ if core.lifecycle.status.isTerminal {
+ return textContent(log: log)
+ }
+
+ var status = "Review \(core.lifecycle.status.rawValue)"
+ if let elapsedSeconds {
+ status += " for \(elapsedSeconds)s"
+ }
+ return "\(status)."
+ }
+
+ func structuredContentForStartOrAwait(log: ReviewMCPLogProjection) -> Value {
+ structuredContent(
+ includeDetails: false,
+ includeNextAction: core.lifecycle.status.isTerminal == false,
+ log: log
+ )
+ }
+
+ func structuredContentForRead(log: ReviewMCPLogProjection) -> Value {
+ structuredContent(
+ includeDetails: true,
+ includeNextAction: false,
+ log: log
+ )
+ }
+
+ func structuredContent(
+ includeDetails: Bool,
+ includeNextAction: Bool,
+ log: ReviewMCPLogProjection
+ ) -> Value {
+ var object: [String: Value] = [
+ "runId": .string(runID),
+ "run": core.run.structuredContent(),
+ "lifecycle": core.structuredLifecycleContent(
+ elapsedSeconds: elapsedSeconds,
+ cancellable: cancellable
+ ),
+ "review": core.structuredReviewContent(
+ resolvedFinalReview: log.finalResult?.nilIfEmpty ?? core.finalReview
+ ),
+ ]
+ object["log"] =
+ includeDetails
+ ? log.structuredContentWithItems()
+ : log.structuredContent()
+ if includeNextAction {
+ object["nextAction"] = .object([
+ "tool": .string(CodexReviewMCP.Tool.Name.reviewAwait.rawValue),
+ "runId": .string(runID),
+ ])
+ }
+ return .object(object)
+ }
+}
+
+private extension ReviewMCPLogProjection {
+ func structuredContent() -> Value {
+ var truncatedFields: [String] = []
+ var object: [String: Value] = [
+ "revision": .string(revision),
+ "orderedEntryIds": .array(orderedEntryIDs.map(Value.string)),
+ "activeEntryIds": .array(activeEntryIDs.map(Value.string)),
+ "activeEntryCount": .int(activeEntryCount),
+ "latestEntryId": latestEntryID.map(Value.string) ?? .null,
+ "finalLifecycleMessage": boundedLogString(
+ finalLifecycleMessage,
+ field: "finalLifecycleMessage",
+ truncatedFields: &truncatedFields
+ ),
+ "finalResult": boundedLogString(
+ finalResult,
+ field: "finalResult",
+ truncatedFields: &truncatedFields
+ ),
+ ]
+ let page = LogEntryPage.unreturned(total: items.count)
+ object["items"] = .array([])
+ object["itemsPage"] = page.structuredContent()
+ object["truncatedFields"] = .array(truncatedFields.map(Value.string))
+ return .object(object)
+ }
+
+ func structuredContentWithItems() -> Value {
+ // Long reviews can accumulate huge transcripts; detailed reads return
+ // a bounded tail page so MCP responses stay small, with the page
+ // metadata pointing at the omitted head. The entry id arrays are
+ // bounded to the same window so no field scales with the full
+ // transcript.
+ let limit = Self.detailedItemsLimit
+ let total = items.count
+ let pageItems = Array(items.suffix(limit))
+ let returned = pageItems.count
+ let offset = total - returned
+ let pageItemIDs = Set(pageItems.map(\.id))
+ let page = LogEntryPage(
+ total: total,
+ offset: offset,
+ limit: limit,
+ returned: returned,
+ hasMoreBefore: offset > 0,
+ hasMoreAfter: false,
+ previousOffset: offset > 0 ? max(0, offset - limit) : nil,
+ nextOffset: nil
+ )
+ var truncatedFields: [String] = []
+ var object: [String: Value] = [
+ "revision": .string(revision),
+ "orderedEntryIds": .array(
+ orderedEntryIDs.filter(pageItemIDs.contains).map(Value.string)
+ ),
+ "activeEntryIds": .array(
+ activeEntryIDs.filter(pageItemIDs.contains).map(Value.string)
+ ),
+ "activeEntryCount": .int(activeEntryCount),
+ "latestEntryId": latestEntryID.map(Value.string) ?? .null,
+ "finalLifecycleMessage": boundedLogString(
+ finalLifecycleMessage,
+ field: "finalLifecycleMessage",
+ truncatedFields: &truncatedFields
+ ),
+ "finalResult": boundedLogString(
+ finalResult,
+ field: "finalResult",
+ truncatedFields: &truncatedFields
+ ),
+ ]
+ object["items"] = .array(pageItems.map { $0.structuredContent() })
+ object["itemsPage"] = page.structuredContent()
+ object["truncatedFields"] = .array(truncatedFields.map(Value.string))
+ return .object(object)
+ }
+
+ private static var detailedItemsLimit: Int { 100 }
+}
+
+private extension ReviewMCPLogProjection.Item {
+ func structuredContent() -> Value {
+ .object([
+ "id": .string(id),
+ "kind": .string(kind),
+ "content": content.structuredContent(),
+ ])
+ }
+}
+
+private extension ReviewMCPLogProjection.Content {
+ func structuredContent() -> Value {
+ switch self {
+ case .diagnostic(let message):
+ var truncatedFields: [String] = []
+ return .object([
+ "type": .string("diagnostic"),
+ "message": boundedLogString(
+ message,
+ field: "message",
+ truncatedFields: &truncatedFields
+ ),
+ "truncatedFields": .array(truncatedFields.map(Value.string)),
+ ])
+ case .message(let text):
+ var truncatedFields: [String] = []
+ return .object([
+ "type": .string("message"),
+ "text": boundedLogString(
+ text,
+ field: "text",
+ truncatedFields: &truncatedFields
+ ),
+ "truncatedFields": .array(truncatedFields.map(Value.string)),
+ ])
+ case .entry(let type, let text):
+ var truncatedFields: [String] = []
+ return .object([
+ "type": .string(type),
+ "text": boundedLogString(
+ text,
+ field: "text",
+ truncatedFields: &truncatedFields
+ ),
+ "truncatedFields": .array(truncatedFields.map(Value.string)),
+ ])
+ }
+ }
+}
+
+private struct LogEntryPage {
+ var total: Int
+ var offset: Int
+ var limit: Int
+ var returned: Int
+ var hasMoreBefore: Bool
+ var hasMoreAfter: Bool
+ var previousOffset: Int?
+ var nextOffset: Int?
+
+ var range: Range {
+ offset.. LogEntryPage {
+ LogEntryPage(
+ total: total,
+ offset: 0,
+ limit: 0,
+ returned: 0,
+ hasMoreBefore: false,
+ hasMoreAfter: total > 0,
+ previousOffset: nil,
+ nextOffset: total > 0 ? 0 : nil
+ )
+ }
+
+ func structuredContent() -> Value {
+ .object([
+ "total": .int(total),
+ "offset": .int(offset),
+ "limit": .int(limit),
+ "returned": .int(returned),
+ "hasMoreBefore": .bool(hasMoreBefore),
+ "hasMoreAfter": .bool(hasMoreAfter),
+ "previousOffset": previousOffset.map(Value.int) ?? .null,
+ "nextOffset": nextOffset.map(Value.int) ?? .null,
+ ])
+ }
+}
+
+private func boundedLogString(
+ _ value: String?,
+ field: String,
+ truncatedFields: inout [String]
+) -> Value {
+ guard let value else {
+ return .null
+ }
+ return boundedLogString(value, field: field, truncatedFields: &truncatedFields)
+}
+
+private func boundedLogString(
+ _ value: String,
+ field: String,
+ truncatedFields: inout [String]
+) -> Value {
+ let bounded = value.boundedLogString()
+ if bounded.wasTruncated {
+ truncatedFields.append(field)
+ }
+ return .string(bounded.value)
+}
+
+private extension String {
+ func boundedLogString() -> (value: String, wasTruncated: Bool) {
+ let limit = 4096
+ guard count > limit else {
+ return (self, false)
+ }
+ let end = index(startIndex, offsetBy: limit)
+ return (String(self[.. Value {
+ .object([
+ "runId": .string(runID),
+ "cwd": .string(cwd),
+ "targetSummary": .string(targetSummary),
+ "run": core.run.structuredContent(),
+ "lifecycle": core.structuredLifecycleContent(
+ elapsedSeconds: elapsedSeconds,
+ cancellable: cancellable
+ ),
+ "review": core.structuredReviewContent(),
+ ])
+ }
+}
+
+private extension CodexReviewAPI.List.Result {
+ func structuredContent() -> Value {
+ .object([
+ "items": .array(items.map { $0.structuredContent() })
+ ])
+ }
+}
+
+private extension CodexReviewAPI.Cancel.Outcome {
+ func textContent() -> String {
+ if cancelled {
+ core.lifecycle.cancellation?.message ?? "Review cancelled."
+ } else {
+ "Review was already finished."
+ }
+ }
+
+ func structuredContent() -> Value {
+ .object([
+ "runId": .string(runID),
+ "cancelled": .bool(cancelled),
+ "run": core.run.structuredContent(),
+ "lifecycle": core.structuredLifecycleContent(
+ elapsedSeconds: nil,
+ cancellable: false
+ ),
+ "review": core.structuredReviewContent(),
+ ])
+ }
+}
+
+private extension ReviewRunCore.Run {
+ func structuredContent() -> Value {
+ .object([
+ "reviewThreadId": reviewThreadID.map(Value.string) ?? .null,
+ "threadId": threadID.map(Value.string) ?? .null,
+ "turnId": turnID.map(Value.string) ?? .null,
+ "model": model.map(Value.string) ?? .null,
+ ])
+ }
+}
+
+private extension ReviewRunCore.Lifecycle {
+ func structuredContent(
+ elapsedSeconds: Int?,
+ cancellable: Bool,
+ message: String
+ ) -> Value {
+ .object([
+ "status": .string(status.rawValue),
+ "message": .string(message),
+ "exitCode": exitCode.map(Value.int) ?? .null,
+ "startedAt": startedAt.map { .string($0.ISO8601Format()) } ?? .null,
+ "endedAt": endedAt.map { .string($0.ISO8601Format()) } ?? .null,
+ "elapsedSeconds": elapsedSeconds.map(Value.int) ?? .null,
+ "cancellable": .bool(cancellable),
+ "cancellation": cancellation.map { $0.structuredContent() } ?? .null,
+ "errorMessage": errorMessage.map(Value.string) ?? .null,
+ ])
+ }
+}
+
+private extension ReviewRunCore {
+ var displayLifecycleMessage: String {
+ lifecycle.errorMessage?.nilIfEmpty ?? lifecycleMessage.nilIfEmpty ?? lifecycle.status.rawValue
+ }
+
+ func structuredLifecycleContent(
+ elapsedSeconds: Int?,
+ cancellable: Bool
+ ) -> Value {
+ lifecycle.structuredContent(
+ elapsedSeconds: elapsedSeconds,
+ cancellable: cancellable,
+ message: displayLifecycleMessage
+ )
+ }
+
+ // The run record is not the transcript's source of truth; read paths that
+ // hold a log projection pass the resolved final review so structured
+ // fields match the text content.
+ func structuredReviewContent(resolvedFinalReview: String? = nil) -> Value {
+ let finalReview = (resolvedFinalReview ?? self.finalReview)?.nilIfEmpty
+ return .object([
+ "hasFinalReview": .bool(finalReview != nil),
+ "finalReview": finalReview.map(Value.string) ?? .null,
+ "reviewResult": ParsedReviewResult.parse(finalReviewText: finalReview).structuredContent(),
+ ])
+ }
+}
+
+private extension ReviewCancellation {
+ func structuredContent() -> Value {
+ .object([
+ "source": .string(source.rawValue),
+ "message": .string(message),
+ ])
+ }
+}
+
+private extension ParsedReviewResult {
+ func structuredContent() -> Value {
+ .object([
+ "state": .string(state.rawValue),
+ "findingCount": findingCount.map(Value.int) ?? .null,
+ "findings": .array(findings.map { $0.structuredContent() }),
+ "source": .string(source.rawValue),
+ "parserVersion": .int(parserVersion),
+ ])
+ }
+}
+
+private extension ParsedReviewResult.Finding {
+ func structuredContent() -> Value {
+ .object([
+ "title": .string(title),
+ "body": .string(body),
+ "priority": priority.map(Value.int) ?? .null,
+ "location": location.map { $0.structuredContent() } ?? .null,
+ "rawText": .string(rawText),
+ ])
+ }
+}
+
+private extension ParsedReviewResult.Finding.Location {
+ func structuredContent() -> Value {
+ .object([
+ "path": .string(path),
+ "startLine": .int(startLine),
+ "endLine": .int(endLine),
+ ])
+ }
+}
diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolSchemas.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolSchemas.swift
new file mode 100644
index 00000000..05306d91
--- /dev/null
+++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolSchemas.swift
@@ -0,0 +1,75 @@
+import MCP
+import CodexReviewKit
+
+func schema(for tool: CodexReviewMCP.Tool.Name) -> Value {
+ switch tool {
+ case .reviewStart:
+ .object([
+ "type": .string("object"),
+ "properties": .object([
+ "sessionID": .object(["type": .string("string")]),
+ "cwd": .object(["type": .string("string")]),
+ "target": .object([
+ "type": .string("object"),
+ "properties": .object([
+ "type": .object(["type": .string("string")]),
+ "branch": .object(["type": .string("string")]),
+ "sha": .object(["type": .string("string")]),
+ "title": .object(["type": .string("string")]),
+ "instructions": .object(["type": .string("string")]),
+ ]),
+ "required": .array([.string("type")]),
+ ]),
+ ]),
+ "required": .array([.string("cwd"), .string("target")]),
+ ])
+ case .reviewAwait:
+ .object([
+ "type": .string("object"),
+ "properties": .object([
+ "sessionID": .object(["type": .string("string")]),
+ "runID": .object(["type": .string("string")]),
+ "runId": .object(["type": .string("string")]),
+ ]),
+ "anyOf": .array([
+ .object(["required": .array([.string("runId")])]),
+ .object(["required": .array([.string("runID")])]),
+ ]),
+ ])
+ case .reviewRead:
+ .object([
+ "type": .string("object"),
+ "properties": .object([
+ "sessionID": .object(["type": .string("string")]),
+ "runID": .object(["type": .string("string")]),
+ "runId": .object(["type": .string("string")]),
+ ]),
+ "anyOf": .array([
+ .object(["required": .array([.string("runId")])]),
+ .object(["required": .array([.string("runID")])]),
+ ]),
+ ])
+ case .reviewList:
+ .object([
+ "type": .string("object"),
+ "properties": .object([
+ "sessionID": .object(["type": .string("string")]),
+ "cwd": .object(["type": .string("string")]),
+ "statuses": .object(["type": .string("array")]),
+ "limit": .object(["type": .string("integer")]),
+ ]),
+ ])
+ case .reviewCancel:
+ .object([
+ "type": .string("object"),
+ "properties": .object([
+ "sessionID": .object(["type": .string("string")]),
+ "runID": .object(["type": .string("string")]),
+ "runId": .object(["type": .string("string")]),
+ "cwd": .object(["type": .string("string")]),
+ "statuses": .object(["type": .string("array")]),
+ "reason": .object(["type": .string("string")]),
+ ]),
+ ])
+ }
+}
diff --git a/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift b/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift
new file mode 100644
index 00000000..acf10b01
--- /dev/null
+++ b/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift
@@ -0,0 +1,179 @@
+import Foundation
+import CodexKit
+import CodexReviewKit
+
+package struct ReviewMCPLogProjection: Sendable, Equatable {
+ struct Item: Sendable, Equatable {
+ var id: String
+ var kind: String
+ var content: Content
+ }
+
+ enum Content: Sendable, Equatable {
+ case message(String)
+ case diagnostic(String)
+ case entry(type: String, text: String)
+
+ var type: String {
+ switch self {
+ case .message:
+ "message"
+ case .diagnostic:
+ "diagnostic"
+ case .entry(let type, _):
+ type
+ }
+ }
+ }
+
+ var revision: String
+ var orderedEntryIDs: [String]
+ var activeEntryIDs: [String]
+ var activeEntryCount: Int
+ var latestEntryID: String?
+ var finalLifecycleMessage: String?
+ var finalResult: String?
+ var items: [Item]
+
+ static func unavailable(result: CodexReviewAPI.Read.Result) -> Self {
+ Self(result: result)
+ }
+
+ private init(result: CodexReviewAPI.Read.Result) {
+ self.revision = "\(result.runID):unavailable"
+ self.items = []
+ self.orderedEntryIDs = []
+ self.activeEntryIDs = []
+ self.activeEntryCount = activeEntryIDs.count
+ self.latestEntryID = orderedEntryIDs.last
+ self.finalLifecycleMessage = nil
+ self.finalResult = nil
+ }
+
+ init(
+ result: CodexReviewAPI.Read.Result,
+ turnID: CodexTurnID,
+ threadItems: [CodexThreadItem]
+ ) {
+ let lifecycle = result.core.lifecycle
+ let lifecycleMessage = result.core.lifecycleMessage
+ let status = lifecycle.status
+ let projectedItems = threadItems.compactMap { item -> Item? in
+ guard let content = Content(threadItem: item) else {
+ return nil
+ }
+ return .init(
+ id: "\(turnID.rawValue):\(item.id)",
+ kind: item.kind.rawValue,
+ content: content
+ )
+ }
+ let itemRevision = threadItems
+ .map { item in
+ // Digest the content, not just its length, so same-length
+ // edits still advance the revision clients compare.
+ "\(item.id):\(item.kind.rawValue):\(item.text?.stableLogDigest ?? "0")"
+ }
+ .joined(separator: "|")
+ self.revision = [
+ result.runID,
+ status.rawValue,
+ lifecycle.endedAt?.timeIntervalSince1970.description ?? "running",
+ turnID.rawValue,
+ itemRevision,
+ ].joined(separator: ":")
+ self.items = projectedItems
+ self.orderedEntryIDs = projectedItems.map(\.id)
+ self.activeEntryIDs = status.isTerminal ? [] : projectedItems.map(\.id)
+ self.activeEntryCount = activeEntryIDs.count
+ self.latestEntryID = orderedEntryIDs.last
+ self.finalLifecycleMessage = status.isTerminal ? lifecycleMessage : nil
+ self.finalResult =
+ status == .succeeded
+ ? projectedItems.lastAssistantMessageText
+ : nil
+ }
+}
+
+private extension [ReviewMCPLogProjection.Item] {
+ // Only agent messages qualify as the final result; a trailing user
+ // prompt in the transcript must never replace the review findings.
+ var lastAssistantMessageText: String? {
+ reversed()
+ .filter { $0.kind == CodexThreadItem.Kind.agentMessage.rawValue }
+ .compactMap { $0.content.messageText }
+ .first
+ }
+}
+
+private extension String {
+ // Process-stable FNV-1a digest; revisions are only compared against
+ // other revisions produced by the same server instance.
+ var stableLogDigest: String {
+ var hash: UInt64 = 0xcbf2_9ce4_8422_2325
+ for byte in utf8 {
+ hash ^= UInt64(byte)
+ hash = hash &* 0x0000_0100_0000_01b3
+ }
+ return String(hash, radix: 16)
+ }
+}
+
+private extension ReviewMCPLogProjection.Content {
+ var messageText: String? {
+ guard case .message(let text) = self else {
+ return nil
+ }
+ return text.nilIfEmpty
+ }
+
+ init?(threadItem item: CodexThreadItem) {
+ switch item.content {
+ case .message(let message):
+ guard let text = message.text.nilIfEmpty else {
+ return nil
+ }
+ self = .message(text)
+ case .diagnostic(let message), .log(let message):
+ guard let message = message.nilIfEmpty else {
+ return nil
+ }
+ self = .diagnostic(message)
+ case .reasoning(let reasoning):
+ guard let text = reasoning.text.nilIfEmpty else {
+ return nil
+ }
+ self = .entry(type: "reasoning", text: text)
+ case .command(let command):
+ guard let text = command.output?.nilIfEmpty ?? command.command.nilIfEmpty else {
+ return nil
+ }
+ self = .entry(type: "command", text: text)
+ case .fileChange(let fileChange):
+ guard let text = fileChange.output?.nilIfEmpty ?? fileChange.path?.nilIfEmpty else {
+ return nil
+ }
+ self = .entry(type: "fileChange", text: text)
+ case .toolCall(let toolCall):
+ guard let text = toolCall.result?.nilIfEmpty
+ ?? toolCall.error?.nilIfEmpty
+ ?? toolCall.name?.nilIfEmpty
+ else {
+ return nil
+ }
+ self = .entry(type: "toolCall", text: text)
+ case .plan(let text):
+ guard let text = text.nilIfEmpty else {
+ return nil
+ }
+ self = .entry(type: "plan", text: text)
+ case .contextCompaction(let message):
+ self = .diagnostic(message?.nilIfEmpty ?? "Context automatically compacted.")
+ case .unknown:
+ guard let text = item.text?.nilIfEmpty else {
+ return nil
+ }
+ self = .entry(type: item.kind.rawValue, text: text)
+ }
+ }
+}
diff --git a/Sources/CodexReviewTesting/TestSupport.swift b/Sources/CodexReviewTesting/TestSupport.swift
index 2cc7413a..a1e913d6 100644
--- a/Sources/CodexReviewTesting/TestSupport.swift
+++ b/Sources/CodexReviewTesting/TestSupport.swift
@@ -1,64 +1,10 @@
import Foundation
-import CodexReview
-import CodexReviewAppServer
-
-package actor AsyncGate {
- private var isOpen = false
- private var waiters: [UUID: CheckedContinuation] = [:]
-
- package init() {}
-
- package func wait() async {
- if isOpen {
- return
- }
- let waiterID = UUID()
- await withTaskCancellationHandler {
- await withCheckedContinuation { continuation in
- if isOpen {
- continuation.resume()
- } else {
- waiters[waiterID] = continuation
- }
- }
- } onCancel: {
- Task {
- await self.cancelWaiter(id: waiterID)
- }
- }
- }
-
- package func waitIgnoringCancellation() async {
- if isOpen {
- return
- }
- let waiterID = UUID()
- await withCheckedContinuation { continuation in
- if isOpen {
- continuation.resume()
- } else {
- waiters[waiterID] = continuation
- }
- }
- }
-
- package func open() {
- guard isOpen == false else {
- return
- }
- isOpen = true
- let waiters = Array(waiters.values)
- self.waiters.removeAll(keepingCapacity: false)
- for waiter in waiters {
- waiter.resume()
- }
- }
-
- private func cancelWaiter(id: UUID) {
- waiters.removeValue(forKey: id)?.resume()
- }
-}
+import CodexAppServerKit
+import CodexAppServerKitTesting
+import CodexReviewKit
+import Synchronization
+package typealias AsyncGate = CodexAppServerTestGate
package typealias OneShotGate = AsyncGate
package actor ManualClock {
@@ -78,22 +24,24 @@ package actor ManualClock {
}
package final class ManualCodexReviewNetworkMonitor: CodexReviewNetworkMonitoring, @unchecked Sendable {
- private let lock = NSLock()
- private var current: CodexReviewNetworkSnapshot?
- private var continuations: [UUID: AsyncStream.Continuation] = [:]
+ private struct State {
+ var current: CodexReviewNetworkSnapshot?
+ var continuations: [UUID: AsyncStream.Continuation] = [:]
+ }
+
+ private let state: Mutex
package init(initialSnapshot: CodexReviewNetworkSnapshot = .satisfied()) {
- self.current = initialSnapshot
+ self.state = Mutex(State(current: initialSnapshot))
}
package func snapshots() -> AsyncStream {
let continuationID = UUID()
return AsyncStream(bufferingPolicy: .unbounded) { continuation in
- let snapshot: CodexReviewNetworkSnapshot?
- lock.lock()
- continuations[continuationID] = continuation
- snapshot = current
- lock.unlock()
+ let snapshot = state.withLock { state in
+ state.continuations[continuationID] = continuation
+ return state.current
+ }
if let snapshot {
continuation.yield(snapshot)
}
@@ -104,20 +52,19 @@ package final class ManualCodexReviewNetworkMonitor: CodexReviewNetworkMonitorin
}
package func yield(_ snapshot: CodexReviewNetworkSnapshot) {
- let continuations: [AsyncStream.Continuation]
- lock.lock()
- current = snapshot
- continuations = Array(self.continuations.values)
- lock.unlock()
+ let continuations = state.withLock { state in
+ state.current = snapshot
+ return Array(state.continuations.values)
+ }
for continuation in continuations {
continuation.yield(snapshot)
}
}
private func removeContinuation(id: UUID) {
- lock.lock()
- continuations.removeValue(forKey: id)
- lock.unlock()
+ _ = state.withLock { state in
+ state.continuations.removeValue(forKey: id)
+ }
}
}
@@ -172,12 +119,11 @@ package actor FakeCodexReviewBackend: CodexReviewBackend {
case readAuth
case startLogin(CodexReviewBackendModel.Login.Request)
case cancelLogin(CodexReviewBackendModel.Login.Challenge)
- case completeLogin(CodexReviewBackendModel.Login.Response)
case logout(CodexReviewBackendModel.Account.ID)
case startReview(CodexReviewBackendModel.Review.Start)
case interruptReview(CodexReviewBackendModel.Review.Run, CodexReviewBackendModel.CancellationReason)
- case beginReviewRecovery(CodexReviewBackendModel.Review.Run, CodexReviewBackendModel.CancellationReason)
- case resumeReviewRecovery(CodexReviewBackendModel.Review.RecoveryToken, CodexReviewBackendModel.Review.Start)
+ case prepareReviewRestart(CodexReviewBackendModel.Review.Run)
+ case restartPreparedReview(CodexReviewBackendModel.Review.RestartToken, CodexReviewBackendModel.Review.Start)
case cleanupReview(CodexReviewBackendModel.Review.Run)
}
@@ -190,11 +136,11 @@ package actor FakeCodexReviewBackend: CodexReviewBackend {
private var recoveryFailureMessage: String?
private var interruptReviewGate: AsyncGate?
private var interruptReviewWaiters: [UUID: CheckedContinuation] = [:]
- private var beginReviewRecoveryWaiters: [UUID: CheckedContinuation] = [:]
+ private var prepareReviewRestartWaiters: [UUID: CheckedContinuation] = [:]
private var startReviewGate: AsyncGate?
private var startReviewWaiters: [UUID: CheckedContinuation] = [:]
- private var resumeReviewRecoveryGate: AsyncGate?
- private var resumeReviewRecoveryWaiters: [UUID: CheckedContinuation] = [:]
+ private var restartPreparedReviewGate: AsyncGate?
+ private var restartPreparedReviewWaiters: [UUID: CheckedContinuation] = [:]
private var eventMailboxes: [EventMailboxKey: BackendReviewEventMailbox] = [:]
private struct EventMailboxKey: Hashable, Sendable {
@@ -216,7 +162,8 @@ package actor FakeCodexReviewBackend: CodexReviewBackend {
package init(
settings: CodexReviewBackendModel.Settings.Snapshot = .init(),
auth: CodexReviewBackendModel.Auth.Snapshot = .init(),
- nextRun: CodexReviewBackendModel.Review.Run = .init(threadID: "thread-1", turnID: "turn-1", reviewThreadID: "review-thread-1")
+ nextRun: CodexReviewBackendModel.Review.Run = .init(
+ threadID: "thread-1", turnID: "turn-1", reviewThreadID: "review-thread-1")
) {
self.settings = settings
self.auth = auth
@@ -243,8 +190,8 @@ package actor FakeCodexReviewBackend: CodexReviewBackend {
interruptReviewGate = gate
}
- package func holdResumeReviewRecovery(with gate: AsyncGate) {
- resumeReviewRecoveryGate = gate
+ package func holdRestartPreparedReview(with gate: AsyncGate) {
+ restartPreparedReviewGate = gate
}
package func setNextRecoveredRun(_ run: CodexReviewBackendModel.Review.Run) {
@@ -327,9 +274,9 @@ package actor FakeCodexReviewBackend: CodexReviewBackend {
}
}
- package func waitForBeginReviewRecovery() async {
+ package func waitForPrepareReviewRestart() async {
if commands.contains(where: {
- if case .beginReviewRecovery = $0 {
+ if case .prepareReviewRestart = $0 {
true
} else {
false
@@ -341,7 +288,7 @@ package actor FakeCodexReviewBackend: CodexReviewBackend {
await withTaskCancellationHandler {
await withCheckedContinuation { continuation in
if commands.contains(where: {
- if case .beginReviewRecovery = $0 {
+ if case .prepareReviewRestart = $0 {
true
} else {
false
@@ -349,25 +296,25 @@ package actor FakeCodexReviewBackend: CodexReviewBackend {
}) {
continuation.resume()
} else {
- beginReviewRecoveryWaiters[waiterID] = continuation
+ prepareReviewRestartWaiters[waiterID] = continuation
}
}
} onCancel: {
Task {
- await self.cancelBeginReviewRecoveryWaiter(id: waiterID)
+ await self.cancelPrepareReviewRestartWaiter(id: waiterID)
}
}
}
- package func waitForBeginReviewRecovery(timeout: Duration = .seconds(2)) async throws {
- try await withFakeBackendTimeout(operation: "beginReviewRecovery", timeout: timeout) {
- await self.waitForBeginReviewRecovery()
+ package func waitForPrepareReviewRestart(timeout: Duration = .seconds(2)) async throws {
+ try await withFakeBackendTimeout(operation: "prepareReviewRestart", timeout: timeout) {
+ await self.waitForPrepareReviewRestart()
}
}
- package func waitForResumeReviewRecovery() async {
+ package func waitForRestartPreparedReview() async {
if commands.contains(where: {
- if case .resumeReviewRecovery = $0 {
+ if case .restartPreparedReview = $0 {
true
} else {
false
@@ -379,7 +326,7 @@ package actor FakeCodexReviewBackend: CodexReviewBackend {
await withTaskCancellationHandler {
await withCheckedContinuation { continuation in
if commands.contains(where: {
- if case .resumeReviewRecovery = $0 {
+ if case .restartPreparedReview = $0 {
true
} else {
false
@@ -387,19 +334,19 @@ package actor FakeCodexReviewBackend: CodexReviewBackend {
}) {
continuation.resume()
} else {
- resumeReviewRecoveryWaiters[waiterID] = continuation
+ restartPreparedReviewWaiters[waiterID] = continuation
}
}
} onCancel: {
Task {
- await self.cancelResumeReviewRecoveryWaiter(id: waiterID)
+ await self.cancelRestartPreparedReviewWaiter(id: waiterID)
}
}
}
- package func waitForResumeReviewRecovery(timeout: Duration = .seconds(2)) async throws {
- try await withFakeBackendTimeout(operation: "resumeReviewRecovery", timeout: timeout) {
- await self.waitForResumeReviewRecovery()
+ package func waitForRestartPreparedReview(timeout: Duration = .seconds(2)) async throws {
+ try await withFakeBackendTimeout(operation: "restartPreparedReview", timeout: timeout) {
+ await self.waitForRestartPreparedReview()
}
}
@@ -408,7 +355,9 @@ package actor FakeCodexReviewBackend: CodexReviewBackend {
return settings
}
- package func applySettings(_ change: CodexReviewBackendModel.Settings.Change) async throws -> CodexReviewBackendModel.Settings.Snapshot {
+ package func applySettings(_ change: CodexReviewBackendModel.Settings.Change) async throws
+ -> CodexReviewBackendModel.Settings.Snapshot
+ {
commands.append(.applySettings(change))
settings = .init(
model: change.updatesModel ? change.model : settings.model,
@@ -425,7 +374,9 @@ package actor FakeCodexReviewBackend: CodexReviewBackend {
return auth
}
- package func startLogin(_ request: CodexReviewBackendModel.Login.Request) async throws -> CodexReviewBackendModel.Login.Challenge {
+ package func startLogin(_ request: CodexReviewBackendModel.Login.Request) async throws
+ -> CodexReviewBackendModel.Login.Challenge
+ {
commands.append(.startLogin(request))
return .init(id: "challenge-1")
}
@@ -434,14 +385,9 @@ package actor FakeCodexReviewBackend: CodexReviewBackend {
commands.append(.cancelLogin(challenge))
}
- package func completeLogin(_ response: CodexReviewBackendModel.Login.Response) async throws -> CodexReviewBackendModel.Auth.Snapshot {
- commands.append(.completeLogin(response))
- let account = CodexReviewBackendModel.Account.Snapshot(id: .init("account-1"), label: "Codex", isActive: true)
- auth = .init(accounts: [account], activeAccountID: account.id)
- return auth
- }
-
- package func logout(_ account: CodexReviewBackendModel.Account.ID) async throws -> CodexReviewBackendModel.Auth.Snapshot {
+ package func logout(_ account: CodexReviewBackendModel.Account.ID) async throws
+ -> CodexReviewBackendModel.Auth.Snapshot
+ {
commands.append(.logout(account))
auth = .init()
return auth
@@ -460,7 +406,9 @@ package actor FakeCodexReviewBackend: CodexReviewBackend {
return .init(run: nextRun, events: eventMailbox(for: nextRun))
}
- package func interruptReview(_ run: CodexReviewBackendModel.Review.Run, reason: CodexReviewBackendModel.CancellationReason) async throws {
+ package func interruptReview(
+ _ run: CodexReviewBackendModel.Review.Run, reason: CodexReviewBackendModel.CancellationReason
+ ) async throws {
commands.append(.interruptReview(run, reason))
let waiters = Array(interruptReviewWaiters.values)
interruptReviewWaiters.removeAll(keepingCapacity: false)
@@ -475,13 +423,12 @@ package actor FakeCodexReviewBackend: CodexReviewBackend {
}
}
- package func beginReviewRecovery(
- _ run: CodexReviewBackendModel.Review.Run,
- reason: CodexReviewBackendModel.CancellationReason
- ) async throws -> CodexReviewBackendModel.Review.RecoveryToken {
- commands.append(.beginReviewRecovery(run, reason))
- let waiters = Array(beginReviewRecoveryWaiters.values)
- beginReviewRecoveryWaiters.removeAll(keepingCapacity: false)
+ package func prepareReviewRestart(
+ _ run: CodexReviewBackendModel.Review.Run
+ ) async throws -> CodexReviewBackendModel.Review.RestartToken {
+ commands.append(.prepareReviewRestart(run))
+ let waiters = Array(prepareReviewRestartWaiters.values)
+ prepareReviewRestartWaiters.removeAll(keepingCapacity: false)
for waiter in waiters {
waiter.resume()
}
@@ -491,33 +438,35 @@ package actor FakeCodexReviewBackend: CodexReviewBackend {
if let interruptFailureMessage {
throw FakeCodexReviewBackendError(message: interruptFailureMessage)
}
- return .init(interruptedRun: run, rollbackThreadID: run.reviewThreadID ?? run.threadID)
+ return .init(id: "restart-token-\(run.attemptID)", interruptedRun: run)
}
- package func resumeReviewRecovery(
- _ token: CodexReviewBackendModel.Review.RecoveryToken,
+ package func restartPreparedReview(
+ _ token: CodexReviewBackendModel.Review.RestartToken,
request: CodexReviewBackendModel.Review.Start
) async throws -> BackendReviewAttempt {
- commands.append(.resumeReviewRecovery(token, request))
- let waiters = Array(resumeReviewRecoveryWaiters.values)
- resumeReviewRecoveryWaiters.removeAll(keepingCapacity: false)
+ commands.append(.restartPreparedReview(token, request))
+ let waiters = Array(restartPreparedReviewWaiters.values)
+ restartPreparedReviewWaiters.removeAll(keepingCapacity: false)
for waiter in waiters {
waiter.resume()
}
- if let resumeReviewRecoveryGate {
- await resumeReviewRecoveryGate.wait()
+ if let restartPreparedReviewGate {
+ await restartPreparedReviewGate.wait()
}
if let recoveryFailureMessage {
throw FakeCodexReviewBackendError(message: recoveryFailureMessage)
}
let run = token.interruptedRun
- let recoveredRun = nextRecoveredRun ?? .init(
- attemptID: "attempt-recovered",
- threadID: run.threadID,
- turnID: "turn-recovered",
- reviewThreadID: run.reviewThreadID,
- model: run.model ?? request.model
- )
+ let recoveredRun =
+ nextRecoveredRun
+ ?? .init(
+ attemptID: "attempt-recovered",
+ threadID: run.threadID,
+ turnID: "turn-recovered",
+ reviewThreadID: run.reviewThreadID,
+ model: run.model ?? request.model
+ )
return .init(run: recoveredRun, events: eventMailbox(for: recoveredRun))
}
@@ -525,7 +474,9 @@ package actor FakeCodexReviewBackend: CodexReviewBackend {
commands.append(.cleanupReview(run))
}
- package func yield(_ event: CodexReviewBackendModel.Review.Event, for run: CodexReviewBackendModel.Review.Run? = nil) async {
+ package func yield(
+ _ event: CodexReviewBackendModel.Review.Event, for run: CodexReviewBackendModel.Review.Run? = nil
+ ) async {
await eventMailbox(for: run ?? nextRun).append(event)
}
@@ -567,12 +518,12 @@ package actor FakeCodexReviewBackend: CodexReviewBackend {
interruptReviewWaiters.removeValue(forKey: id)?.resume()
}
- private func cancelBeginReviewRecoveryWaiter(id: UUID) {
- beginReviewRecoveryWaiters.removeValue(forKey: id)?.resume()
+ private func cancelPrepareReviewRestartWaiter(id: UUID) {
+ prepareReviewRestartWaiters.removeValue(forKey: id)?.resume()
}
- private func cancelResumeReviewRecoveryWaiter(id: UUID) {
- resumeReviewRecoveryWaiters.removeValue(forKey: id)?.resume()
+ private func cancelRestartPreparedReviewWaiter(id: UUID) {
+ restartPreparedReviewWaiters.removeValue(forKey: id)?.resume()
}
}
@@ -586,58 +537,44 @@ package final class StoreSnapshotProbe {
}
package func snapshot() -> StoreSnapshot {
- let jobs = store.jobs
+ let reviewRuns = store.reviewRuns
.sorted { lhs, rhs in
if lhs.sortOrder == rhs.sortOrder {
return lhs.id < rhs.id
}
return lhs.sortOrder > rhs.sortOrder
}
- .map { job in
- StoreJobSnapshot(
- jobID: job.id,
- status: job.core.lifecycle.status,
- summary: job.core.output.summary,
- lastAgentMessage: job.core.output.lastAgentMessage,
- logs: job.logEntries,
- run: job.core.run,
- activeRun: store.activeRuns[job.id],
- cancellationRequested: job.cancellationRequested
+ .map { runRecord in
+ let runtimeState = store.runtimeReviewRunState(runID: runRecord.id)
+ return StoreRunSnapshot(
+ runID: runRecord.id,
+ status: runRecord.core.lifecycle.status,
+ summary: runRecord.core.lifecycleMessage,
+ run: runRecord.core.run,
+ activeRun: runtimeState.activeRun,
+ cancellationRequested: runRecord.cancellationRequested
)
}
- return StoreSnapshot(jobs: jobs)
+ return StoreSnapshot(reviewRuns: reviewRuns)
}
- package func waitUntilJobStatus(
- _ status: ReviewJobState,
- jobID: String? = nil,
+ package func waitUntilRunStatus(
+ _ status: ReviewRunState,
+ runID: String? = nil,
timeout: Duration = .seconds(2)
) async -> StoreSnapshot? {
await waitUntil(timeout: timeout) { snapshot in
- snapshot.job(jobID)?.status == status
- }
- }
-
- package func waitUntilLogs(
- jobID: String? = nil,
- timeout: Duration = .seconds(2),
- matching predicate: @escaping @MainActor (Array) -> Bool
- ) async -> StoreSnapshot? {
- await waitUntil(timeout: timeout) { snapshot in
- guard let job = snapshot.job(jobID) else {
- return false
- }
- return predicate(job.logs)
+ snapshot.run(runID)?.status == status
}
}
package func waitUntilRunAttempt(
_ attemptID: String,
- jobID: String? = nil,
+ runID: String? = nil,
timeout: Duration = .seconds(2)
) async -> StoreSnapshot? {
await waitUntil(timeout: timeout) { snapshot in
- snapshot.job(jobID)?.activeRun?.attemptID == attemptID
+ snapshot.run(runID)?.activeRun?.attemptID == attemptID
}
}
@@ -661,23 +598,21 @@ package final class StoreSnapshotProbe {
}
package struct StoreSnapshot: Sendable {
- package var jobs: [StoreJobSnapshot]
+ package var reviewRuns: [StoreRunSnapshot]
- package func job(_ jobID: String? = nil) -> StoreJobSnapshot? {
- guard let jobID else {
- return jobs.first
+ package func run(_ runID: String? = nil) -> StoreRunSnapshot? {
+ guard let runID else {
+ return reviewRuns.first
}
- return jobs.first { $0.jobID == jobID }
+ return reviewRuns.first { $0.runID == runID }
}
}
-package struct StoreJobSnapshot: Sendable {
- package var jobID: String
- package var status: ReviewJobState
+package struct StoreRunSnapshot: Sendable {
+ package var runID: String
+ package var status: ReviewRunState
package var summary: String
- package var lastAgentMessage: String?
- package var logs: [ReviewLogEntry]
- package var run: ReviewJobCore.Run
+ package var run: ReviewRunCore.Run
package var activeRun: CodexReviewBackendModel.Review.Run?
package var cancellationRequested: Bool
}
@@ -720,12 +655,12 @@ package final class TestingCodexReviewStoreBackend: CodexReviewStoreBackend {
package func refreshAuth(auth: CodexReviewAuthModel) async {
do {
let snapshot = try await reviewBackend.readAuth()
- let accounts = snapshot.accounts.compactMap { account -> CodexAccount? in
+ let accounts = snapshot.accounts.compactMap { account -> CodexReviewKit.CodexReviewAccount? in
let label = account.label.trimmingCharacters(in: .whitespacesAndNewlines)
guard label.isEmpty == false else {
return nil
}
- return CodexAccount(email: label)
+ return CodexReviewKit.CodexReviewAccount(email: label)
}
auth.applyPersistedAccountStates(
accounts.map(savedAccountPayload(from:)),
@@ -741,12 +676,14 @@ package final class TestingCodexReviewStoreBackend: CodexReviewStoreBackend {
package func signIn(auth: CodexReviewAuthModel) async {
do {
let challenge = try await reviewBackend.startLogin(.init())
- auth.updatePhase(.signingIn(.init(
- title: "Sign in to Codex",
- detail: "Complete sign in in your browser, then return to ReviewMonitor.",
- browserURL: challenge.verificationURL?.absoluteString,
- userCode: challenge.userCode
- )))
+ auth.updatePhase(
+ .signingIn(
+ .init(
+ title: "Sign in to Codex",
+ detail: "Complete sign in in your browser, then return to ReviewMonitor.",
+ browserURL: challenge.verificationURL?.absoluteString,
+ userCode: challenge.userCode
+ )))
} catch {
auth.updatePhase(.failed(message: error.localizedDescription))
}
@@ -825,18 +762,17 @@ package final class TestingCodexReviewStoreBackend: CodexReviewStoreBackend {
try await reviewBackend.interruptReview(run, reason: reason)
}
- package func beginReviewRecovery(
- _ run: CodexReviewBackendModel.Review.Run,
- reason: CodexReviewBackendModel.CancellationReason
- ) async throws -> CodexReviewBackendModel.Review.RecoveryToken {
- try await reviewBackend.beginReviewRecovery(run, reason: reason)
+ package func prepareReviewRestart(
+ _ run: CodexReviewBackendModel.Review.Run
+ ) async throws -> CodexReviewBackendModel.Review.RestartToken {
+ try await reviewBackend.prepareReviewRestart(run)
}
- package func resumeReviewRecovery(
- _ token: CodexReviewBackendModel.Review.RecoveryToken,
+ package func restartPreparedReview(
+ _ token: CodexReviewBackendModel.Review.RestartToken,
request: CodexReviewBackendModel.Review.Start
) async throws -> BackendReviewAttempt {
- try await reviewBackend.resumeReviewRecovery(token, request: request)
+ try await reviewBackend.restartPreparedReview(token, request: request)
}
package func cleanupReview(_ run: CodexReviewBackendModel.Review.Run) async {
@@ -885,220 +821,4 @@ package final class TestingCodexReviewStoreBackend: CodexReviewStoreBackend {
}
}
-package actor FakeJSONRPCTransport: JSONRPC.Transport {
- private struct RequestGate: Sendable {
- var gate: AsyncGate
- var ignoresCancellation: Bool
-
- func wait() async {
- if ignoresCancellation {
- await gate.waitIgnoringCancellation()
- } else {
- await gate.wait()
- }
- }
- }
-
- private enum QueuedResponse: Sendable {
- case success(Data)
- case failure(JSONRPC.Error)
- }
-
- private var responses: [String: [QueuedResponse]]
- private var requests: [JSONRPC.Request] = []
- private var notifications: [JSONRPC.Notification] = []
- private var serverNotificationContinuations: [AsyncThrowingStream.Continuation] = []
- private var activeByMethod: [String: Int] = [:]
- private var maxActiveByMethod: [String: Int] = [:]
- private var gatesByMethod: [String: RequestGate] = [:]
- private var oneShotGatesByMethod: [String: [RequestGate]] = [:]
- private var requestCountWaiters: [(Int, CheckedContinuation)] = []
- private var notificationStreamCountWaiters: [(Int, CheckedContinuation)] = []
- private var closed = false
-
- package init(responses: [String: [Data]] = [:]) {
- self.responses = responses
- .mapValues { $0.map(QueuedResponse.success) }
- }
-
- package func enqueue(
- _ response: Response,
- for method: String
- ) throws {
- let data = try JSONEncoder().encode(response)
- responses[method, default: []].append(.success(data))
- }
-
- package func enqueueFailure(
- _ error: JSONRPC.Error,
- for method: String
- ) {
- responses[method, default: []].append(.failure(error))
- }
-
- package func hold(method: String, gate: AsyncGate) {
- gatesByMethod[method] = .init(gate: gate, ignoresCancellation: false)
- }
-
- package func holdNext(method: String, gate: AsyncGate) {
- oneShotGatesByMethod[method, default: []].append(.init(gate: gate, ignoresCancellation: false))
- }
-
- package func holdNextIgnoringCancellation(method: String, gate: AsyncGate) {
- oneShotGatesByMethod[method, default: []].append(.init(gate: gate, ignoresCancellation: true))
- }
-
- package func send(_ request: JSONRPC.Request) async throws -> Data {
- guard closed == false else {
- throw JSONRPC.Error.closed
- }
- requests.append(request)
- resumeRequestCountWaiters()
- 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] {
- await gate.wait()
- }
- activeByMethod[request.method, default: 1] -= 1
- if let queuedResponse {
- switch queuedResponse {
- case .success(let data):
- return data
- case .failure(let error):
- throw error
- }
- }
- return try JSONEncoder().encode(EmptyResponse())
- }
-
- 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
- }
-
- package func notify(_ notification: JSONRPC.Notification) async throws {
- notifications.append(notification)
- }
-
- package func notificationStream() -> AsyncThrowingStream {
- AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in
- serverNotificationContinuations.append(continuation)
- resumeNotificationStreamCountWaiters()
- }
- }
-
- package func close() async {
- closed = true
- for continuation in serverNotificationContinuations {
- continuation.finish()
- }
- serverNotificationContinuations.removeAll()
- }
-
- package func finishNotificationStreams(throwing error: any Error) {
- for continuation in serverNotificationContinuations {
- continuation.finish(throwing: error)
- }
- serverNotificationContinuations.removeAll()
- }
-
- package func recordedRequests() -> [JSONRPC.Request] {
- requests
- }
-
- package 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))
- }
- }
- }
-
- package func recordedNotifications() -> [JSONRPC.Notification] {
- notifications
- }
-
- package func waitForNotificationStreamCount(_ count: Int) async {
- if serverNotificationContinuations.count >= count {
- return
- }
- await withCheckedContinuation { continuation in
- if serverNotificationContinuations.count >= count {
- continuation.resume()
- } else {
- notificationStreamCountWaiters.append((count, continuation))
- }
- }
- }
-
- package func notificationStreamCount() -> Int {
- serverNotificationContinuations.count
- }
-
- package func isClosedForTesting() -> Bool {
- closed
- }
-
- package func maxActiveCount(for method: String) -> Int {
- maxActiveByMethod[method] ?? 0
- }
-
- package func emitServerNotification(
- method: String,
- params: Params
- ) throws {
- let notification = JSONRPC.Notification(
- method: method,
- params: try JSONEncoder().encode(params)
- )
- for continuation in serverNotificationContinuations {
- continuation.yield(notification)
- }
- }
-
- 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 resumeNotificationStreamCountWaiters() {
- var remaining: [(Int, CheckedContinuation)] = []
- for waiter in notificationStreamCountWaiters {
- if serverNotificationContinuations.count >= waiter.0 {
- waiter.1.resume()
- } else {
- remaining.append(waiter)
- }
- }
- notificationStreamCountWaiters = remaining
- }
-}
+package typealias FakeCodexAppServerTransport = CodexAppServerTestTransport
diff --git a/Sources/ReviewUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift b/Sources/ReviewChatLogUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift
similarity index 78%
rename from Sources/ReviewUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift
rename to Sources/ReviewChatLogUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift
index a83d15d6..12ff4054 100644
--- a/Sources/ReviewUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift
+++ b/Sources/ReviewChatLogUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift
@@ -1,5 +1,4 @@
import Foundation
-import CodexReview
enum ReviewMonitorCommandOutputDisplayDocument {
static let toggleAttachmentCharacter = "\u{fffc}"
@@ -12,12 +11,13 @@ enum ReviewMonitorCommandOutputDisplayDocument {
let nextIndex = displayText.index(after: index)
if character == "\n",
- nextIndex < displayText.endIndex,
- String(displayText[nextIndex]) == toggleAttachmentCharacter {
+ nextIndex < displayText.endIndex,
+ String(displayText[nextIndex]) == toggleAttachmentCharacter
+ {
let afterAttachmentIndex = displayText.index(after: nextIndex)
- if afterAttachmentIndex == displayText.endIndex ||
- displayText[afterAttachmentIndex] == "\n" ||
- String(displayText[afterAttachmentIndex]) == toggleAttachmentCharacter {
+ if afterAttachmentIndex == displayText.endIndex || displayText[afterAttachmentIndex] == "\n"
+ || String(displayText[afterAttachmentIndex]) == toggleAttachmentCharacter
+ {
index = afterAttachmentIndex
continue
}
@@ -124,45 +124,49 @@ enum ReviewMonitorCommandOutputDisplayDocument {
title: title,
includesActiveTimer: isActive && metadata?.startedAt != nil
)
- blocks.append(.init(
- id: blockID,
- kind: .commandOutput,
- groupID: panelSource.anchor.groupID,
- range: displayRange,
- sourceRange: commandPanelSourceRange(panelSource),
- metadata: metadata
- ))
- styleRuns.append(.init(
- range: controlRange,
- style: .commandOutputControl(keepsTrailingContent: isActive && metadata?.startedAt != nil)
- ))
- panels.append(.init(
- blockID: blockID,
- range: displayRange,
- commandText: commandText,
- outputText: outputText,
- outputSourceRange: panelSource.output?.sourceRange,
- lineCount: commandOutputLineCount(
- for: panelSource,
- sourceString: sourceString,
- isExpanded: isExpanded
- ),
- isExpanded: isExpanded,
- isActive: isActive,
- startedAt: metadata?.startedAt,
- title: title,
- exitText: commandOutputResultText(for: metadata)
- ))
+ blocks.append(
+ .init(
+ id: blockID,
+ kind: .commandOutput,
+ groupID: panelSource.anchor.groupID,
+ range: displayRange,
+ sourceRange: commandPanelSourceRange(panelSource),
+ metadata: metadata
+ ))
+ styleRuns.append(
+ .init(
+ range: controlRange,
+ style: .commandOutputControl(keepsTrailingContent: isActive && metadata?.startedAt != nil)
+ ))
+ panels.append(
+ .init(
+ blockID: blockID,
+ range: displayRange,
+ commandText: commandText,
+ outputText: outputText,
+ outputSourceRange: panelSource.output?.sourceRange,
+ lineCount: commandOutputLineCount(
+ for: panelSource,
+ sourceString: sourceString,
+ isExpanded: isExpanded
+ ),
+ isExpanded: isExpanded,
+ isActive: isActive,
+ startedAt: metadata?.startedAt,
+ title: title,
+ exitText: commandOutputResultText(for: metadata)
+ ))
} else {
let displayRange = appendText(sourceString.substring(with: block.range))
- blocks.append(.init(
- id: block.id,
- kind: block.kind,
- groupID: block.groupID,
- range: displayRange,
- sourceRange: block.sourceRange,
- metadata: block.metadata
- ))
+ blocks.append(
+ .init(
+ id: block.id,
+ kind: block.kind,
+ groupID: block.groupID,
+ range: displayRange,
+ sourceRange: block.sourceRange,
+ metadata: block.metadata
+ ))
appendPresentationRuns(
from: source,
sourceRange: block.range,
@@ -206,7 +210,7 @@ enum ReviewMonitorCommandOutputDisplayDocument {
var output: ReviewMonitorLog.Block?
}
- private static func commandPanelMetadata(for source: CommandPanelSource) -> ReviewLogEntry.Metadata? {
+ private static func commandPanelMetadata(for source: CommandPanelSource) -> ReviewMonitorLog.Metadata? {
mergeCommandMetadata(
primary: source.output?.metadata,
fallback: source.command?.metadata ?? source.anchor.metadata
@@ -214,9 +218,9 @@ enum ReviewMonitorCommandOutputDisplayDocument {
}
private static func mergeCommandMetadata(
- primary: ReviewLogEntry.Metadata?,
- fallback: ReviewLogEntry.Metadata?
- ) -> ReviewLogEntry.Metadata? {
+ primary: ReviewMonitorLog.Metadata?,
+ fallback: ReviewMonitorLog.Metadata?
+ ) -> ReviewMonitorLog.Metadata? {
guard let primary else {
return fallback
}
@@ -224,10 +228,12 @@ enum ReviewMonitorCommandOutputDisplayDocument {
return primary
}
- let durationMs = primary.durationMs ?? fallback.durationMs ?? commandDurationMs(
- startedAt: primary.startedAt ?? fallback.startedAt,
- completedAt: primary.completedAt ?? fallback.completedAt
- )
+ let durationMs =
+ primary.durationMs ?? fallback.durationMs
+ ?? commandDurationMs(
+ startedAt: primary.startedAt ?? fallback.startedAt,
+ completedAt: primary.completedAt ?? fallback.completedAt
+ )
let title = primary.title ?? fallback.title
let status = primary.status ?? fallback.status
let detail = primary.detail ?? fallback.detail
@@ -247,7 +253,7 @@ enum ReviewMonitorCommandOutputDisplayDocument {
let resultText = primary.resultText ?? fallback.resultText
let errorText = primary.errorText ?? fallback.errorText
- return ReviewLogEntry.Metadata(
+ return ReviewMonitorLog.Metadata(
sourceType: primary.sourceType,
title: title,
status: status,
@@ -273,12 +279,12 @@ enum ReviewMonitorCommandOutputDisplayDocument {
private static func firstBlocksByGroupID(
in blocks: [ReviewMonitorLog.Block],
- kind: ReviewLogEntry.Kind
+ kind: ReviewMonitorLog.Kind
) -> [String: ReviewMonitorLog.Block] {
var result: [String: ReviewMonitorLog.Block] = [:]
for block in blocks where block.kind == kind {
guard let groupID = block.groupID,
- result[groupID] == nil
+ result[groupID] == nil
else {
continue
}
@@ -298,7 +304,7 @@ enum ReviewMonitorCommandOutputDisplayDocument {
return .init(anchor: block, command: block, output: output)
case .commandOutput:
guard let groupID = block.groupID,
- let command = commandBlocksByGroupID[groupID]
+ let command = commandBlocksByGroupID[groupID]
else {
return .init(anchor: block, command: nil, output: block)
}
@@ -308,7 +314,7 @@ enum ReviewMonitorCommandOutputDisplayDocument {
}
return .init(anchor: anchor, command: command, output: block)
case .agentMessage, .plan, .todoList, .reasoning, .reasoningSummary, .rawReasoning,
- .toolCall, .diagnostic, .error, .progress, .event, .contextCompaction:
+ .toolCall, .diagnostic, .error, .progress, .event, .contextCompaction:
return nil
}
}
@@ -319,8 +325,8 @@ enum ReviewMonitorCommandOutputDisplayDocument {
commandOutputBlocksByGroupID: [String: ReviewMonitorLog.Block]
) -> Bool {
guard let groupID = block.groupID,
- let command = commandBlocksByGroupID[groupID],
- let output = commandOutputBlocksByGroupID[groupID]
+ let command = commandBlocksByGroupID[groupID],
+ let output = commandOutputBlocksByGroupID[groupID]
else {
return false
}
@@ -384,10 +390,11 @@ enum ReviewMonitorCommandOutputDisplayDocument {
guard intersection.length > 0 else {
continue
}
- styleRuns.append(.init(
- range: map(intersection, from: sourceRange, to: displayRange),
- style: styleRun.style
- ))
+ styleRuns.append(
+ .init(
+ range: map(intersection, from: sourceRange, to: displayRange),
+ style: styleRun.style
+ ))
}
for decoration in source.decorations {
@@ -395,11 +402,12 @@ enum ReviewMonitorCommandOutputDisplayDocument {
guard intersection.length > 0 else {
continue
}
- decorations.append(.init(
- blockID: decoration.blockID,
- range: map(intersection, from: sourceRange, to: displayRange),
- style: decoration.style
- ))
+ decorations.append(
+ .init(
+ blockID: decoration.blockID,
+ range: map(intersection, from: sourceRange, to: displayRange),
+ style: decoration.style
+ ))
}
}
@@ -426,11 +434,9 @@ enum ReviewMonitorCommandOutputDisplayDocument {
) -> NSRange {
NSRange(
location: displayRange.location,
- length: (
- toggleAttachmentCharacter
- + title
- + (includesActiveTimer ? toggleAttachmentCharacter : "")
- ).utf16.count
+ length: (toggleAttachmentCharacter
+ + title
+ + (includesActiveTimer ? toggleAttachmentCharacter : "")).utf16.count
)
}
@@ -463,28 +469,30 @@ enum ReviewMonitorCommandOutputDisplayDocument {
}
private static func commandOutputTitle(
- metadata: ReviewLogEntry.Metadata?,
+ metadata: ReviewMonitorLog.Metadata?,
commandText: String,
isActive: Bool,
currentDate: Date
) -> String {
if hasStructuredCommandMetadata(metadata) {
- let title = commandActionTitle(
- metadata: metadata,
- commandText: commandText,
- isActive: isActive
- )
- ?? commandRunTitle(
- commandText: commandText,
- metadata: metadata,
- isActive: isActive
- )
+ let title =
+ commandActionTitle(
+ metadata: metadata,
+ commandText: commandText,
+ isActive: isActive
+ )
+ ?? commandRunTitle(
+ commandText: commandText,
+ metadata: metadata,
+ isActive: isActive
+ )
if isActive == false,
- let durationText = commandDurationText(
- metadata: metadata,
- isActive: isActive,
- currentDate: currentDate
- ) {
+ let durationText = commandDurationText(
+ metadata: metadata,
+ isActive: isActive,
+ currentDate: currentDate
+ )
+ {
return "\(title) for \(durationText)"
}
return title
@@ -492,8 +500,9 @@ enum ReviewMonitorCommandOutputDisplayDocument {
let trimmedTitle = metadata?.title?.trimmingCharacters(in: .whitespacesAndNewlines)
if let trimmedTitle,
- trimmedTitle.isEmpty == false,
- isGenericCommandTitle(trimmedTitle) == false {
+ trimmedTitle.isEmpty == false,
+ isGenericCommandTitle(trimmedTitle) == false
+ {
return trimmedTitle
}
if commandText.isEmpty == false {
@@ -503,7 +512,7 @@ enum ReviewMonitorCommandOutputDisplayDocument {
return "Command output"
}
- private static func hasStructuredCommandMetadata(_ metadata: ReviewLogEntry.Metadata?) -> Bool {
+ private static func hasStructuredCommandMetadata(_ metadata: ReviewMonitorLog.Metadata?) -> Bool {
guard let metadata else {
return false
}
@@ -517,7 +526,7 @@ enum ReviewMonitorCommandOutputDisplayDocument {
private static func commandRunTitle(
commandText: String,
- metadata: ReviewLogEntry.Metadata?,
+ metadata: ReviewMonitorLog.Metadata?,
isActive: Bool
) -> String {
let command = commandText.nilIfEmpty ?? metadata?.command?.nilIfEmpty ?? "command"
@@ -525,12 +534,12 @@ enum ReviewMonitorCommandOutputDisplayDocument {
}
private static func commandActionTitle(
- metadata: ReviewLogEntry.Metadata?,
+ metadata: ReviewMonitorLog.Metadata?,
commandText: String,
isActive: Bool
) -> String? {
guard let actions = metadata?.commandActions,
- actions.isEmpty == false
+ actions.isEmpty == false
else {
return nil
}
@@ -567,17 +576,16 @@ enum ReviewMonitorCommandOutputDisplayDocument {
}
private static func commandOutputIsActive(
- _ metadata: ReviewLogEntry.Metadata?,
+ _ metadata: ReviewMonitorLog.Metadata?,
hasOutput: Bool
) -> Bool {
guard let metadata else {
return hasOutput == false
}
- let status = (metadata.commandStatus ?? metadata.status)?
- .trimmingCharacters(in: .whitespacesAndNewlines)
- .lowercased()
+ let status = normalizedCommandStatus(metadata)
switch status {
- case "completed", "succeeded", "success", "failed", "failure", "errored", "declined", "canceled", "cancelled":
+ case "completed", "succeeded", "success", "failed", "failure", "errored", "declined", "canceled",
+ "cancelled", "interrupted":
return false
case "inprogress", "in_progress", "started", "running":
return true
@@ -590,8 +598,14 @@ enum ReviewMonitorCommandOutputDisplayDocument {
return true
}
+ private static func normalizedCommandStatus(_ metadata: ReviewMonitorLog.Metadata?) -> String? {
+ (metadata?.commandStatus ?? metadata?.status)?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .lowercased()
+ }
+
private static func commandDurationText(
- metadata: ReviewLogEntry.Metadata?,
+ metadata: ReviewMonitorLog.Metadata?,
isActive: Bool,
currentDate: Date
) -> String? {
@@ -599,10 +613,12 @@ enum ReviewMonitorCommandOutputDisplayDocument {
if isActive, let startedAt = metadata?.startedAt {
durationMs = commandDurationMs(startedAt: startedAt, completedAt: currentDate)
} else {
- durationMs = metadata?.durationMs ?? commandDurationMs(
- startedAt: metadata?.startedAt,
- completedAt: metadata?.completedAt
- )
+ durationMs =
+ metadata?.durationMs
+ ?? commandDurationMs(
+ startedAt: metadata?.startedAt,
+ completedAt: metadata?.completedAt
+ )
}
guard let durationMs else {
return nil
@@ -616,7 +632,8 @@ enum ReviewMonitorCommandOutputDisplayDocument {
commandTextByGroupID: [String: String]
) -> String {
if let groupID = panelSource.anchor.groupID,
- let commandText = commandTextByGroupID[groupID] {
+ let commandText = commandTextByGroupID[groupID]
+ {
return commandText
}
@@ -640,15 +657,13 @@ enum ReviewMonitorCommandOutputDisplayDocument {
return normalized == "command" || normalized == "command output"
}
- private static func commandOutputResultText(for metadata: ReviewLogEntry.Metadata?) -> String? {
+ private static func commandOutputResultText(for metadata: ReviewMonitorLog.Metadata?) -> String? {
if let exitCode = metadata?.exitCode {
return exitCode == 0 ? "Success" : "exit \(exitCode)"
}
let rawStatus = metadata?.commandStatus ?? metadata?.status
- let normalizedStatus = rawStatus?
- .trimmingCharacters(in: .whitespacesAndNewlines)
- .lowercased()
+ let normalizedStatus = normalizedCommandStatus(metadata)
if normalizedStatus == "succeeded" || normalizedStatus == "success" || normalizedStatus == "completed" {
return "Success"
}
@@ -658,6 +673,12 @@ enum ReviewMonitorCommandOutputDisplayDocument {
}
return "Failed"
}
+ if normalizedStatus == "interrupted" {
+ return "Interrupted"
+ }
+ if normalizedStatus == "cancelled" || normalizedStatus == "canceled" {
+ return "Cancelled"
+ }
return rawStatus?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
}
@@ -666,13 +687,13 @@ enum ReviewMonitorCommandOutputDisplayDocument {
return components.prefix(2).joined(separator: " ")
}
- private static func readActionLabel(_ action: ReviewLogEntry.Metadata.CommandAction) -> String? {
+ private static func readActionLabel(_ action: ReviewMonitorLog.Metadata.CommandAction) -> String? {
action.name?.nilIfEmpty
?? action.path?.nilIfEmpty.map { URL(fileURLWithPath: $0).lastPathComponent.nilIfEmpty ?? $0 }
?? action.command?.nilIfEmpty
}
- private static func searchActionLabel(_ action: ReviewLogEntry.Metadata.CommandAction) -> String? {
+ private static func searchActionLabel(_ action: ReviewMonitorLog.Metadata.CommandAction) -> String? {
let query = action.query?.nilIfEmpty
let path = action.path?.nilIfEmpty.map(actionPathLabel)
switch (query, path) {
@@ -687,7 +708,7 @@ enum ReviewMonitorCommandOutputDisplayDocument {
}
}
- private static func listActionLabel(_ action: ReviewLogEntry.Metadata.CommandAction) -> String? {
+ private static func listActionLabel(_ action: ReviewMonitorLog.Metadata.CommandAction) -> String? {
action.path?.nilIfEmpty.map(actionPathLabel) ?? action.command?.nilIfEmpty
}
@@ -743,7 +764,7 @@ enum ReviewMonitorCommandOutputDisplayDocument {
var commandTextByGroupID: [String: String] = [:]
for block in blocks where block.kind == .command {
guard let groupID = block.groupID,
- commandTextByGroupID[groupID] == nil
+ commandTextByGroupID[groupID] == nil
else {
continue
}
@@ -773,12 +794,14 @@ enum ReviewMonitorCommandOutputDisplayDocument {
) -> ReviewMonitorLog.Change {
switch change {
case .append(let append):
- guard let mappedRange = mappedChangeRange(
- append.range,
- blockID: append.blockID,
- sourceBlocks: sourceBlocks,
- displayBlocks: displayBlocks
- ) else {
+ guard
+ let mappedRange = mappedChangeRange(
+ append.range,
+ blockID: append.blockID,
+ sourceBlocks: sourceBlocks,
+ displayBlocks: displayBlocks
+ )
+ else {
if let panelMutation = mappedCommandPanelMutation(
sourceRange: append.range,
blockID: append.blockID,
@@ -791,21 +814,24 @@ enum ReviewMonitorCommandOutputDisplayDocument {
}
return .reload
}
- return .append(.init(
- kind: append.kind,
- blockID: append.blockID,
- range: mappedRange,
- text: append.text,
- textUTF16Length: append.textUTF16Length,
- animationSpans: append.animationSpans
- ))
+ return .append(
+ .init(
+ kind: append.kind,
+ blockID: append.blockID,
+ range: mappedRange,
+ text: append.text,
+ textUTF16Length: append.textUTF16Length,
+ animationSpans: append.animationSpans
+ ))
case .replace(let replacement):
- guard let mappedRange = mappedChangeRange(
- replacement.range,
- blockID: replacement.blockID,
- sourceBlocks: sourceBlocks,
- displayBlocks: displayBlocks
- ) else {
+ guard
+ let mappedRange = mappedChangeRange(
+ replacement.range,
+ blockID: replacement.blockID,
+ sourceBlocks: sourceBlocks,
+ displayBlocks: displayBlocks
+ )
+ else {
if let panelMutation = mappedCommandPanelMutation(
sourceRange: replacement.range,
blockID: replacement.blockID,
@@ -818,13 +844,14 @@ enum ReviewMonitorCommandOutputDisplayDocument {
}
return .reload
}
- return .replace(.init(
- kind: replacement.kind,
- blockID: replacement.blockID,
- range: mappedRange,
- text: replacement.text,
- textUTF16Length: replacement.textUTF16Length
- ))
+ return .replace(
+ .init(
+ kind: replacement.kind,
+ blockID: replacement.blockID,
+ range: mappedRange,
+ text: replacement.text,
+ textUTF16Length: replacement.textUTF16Length
+ ))
case .reload:
return .reload
}
@@ -839,12 +866,12 @@ enum ReviewMonitorCommandOutputDisplayDocument {
displayText: String
) -> ReviewMonitorLog.Change? {
guard let sourceBlock = sourceBlocks.first(where: { $0.id == blockID }),
- sourceBlock.kind == .command || sourceBlock.kind == .commandOutput,
- NSMaxRange(sourceRange) <= NSMaxRange(sourceBlock.range),
- let displayBlock = commandPanelDisplayBlock(
+ sourceBlock.kind == .command || sourceBlock.kind == .commandOutput,
+ NSMaxRange(sourceRange) <= NSMaxRange(sourceBlock.range),
+ let displayBlock = commandPanelDisplayBlock(
for: sourceBlock,
displayBlocks: displayBlocks
- )
+ )
else {
return nil
}
@@ -858,20 +885,23 @@ enum ReviewMonitorCommandOutputDisplayDocument {
for: sourceBlock,
displayBlocks: previousDisplay?.blocks ?? []
)?.range {
- return .replace(.init(
- kind: displayBlock.kind,
- blockID: displayBlock.id,
- range: previousRange,
- text: replacementText,
- textUTF16Length: displayBlock.range.length
- ))
- }
-
- guard let append = mappedNewCommandPanelAppend(
- displayBlock: displayBlock,
- displayText: displayText,
- previousDisplay: previousDisplay
- ) else {
+ return .replace(
+ .init(
+ kind: displayBlock.kind,
+ blockID: displayBlock.id,
+ range: previousRange,
+ text: replacementText,
+ textUTF16Length: displayBlock.range.length
+ ))
+ }
+
+ guard
+ let append = mappedNewCommandPanelAppend(
+ displayBlock: displayBlock,
+ displayText: displayText,
+ previousDisplay: previousDisplay
+ )
+ else {
return nil
}
return .append(append)
@@ -900,7 +930,7 @@ enum ReviewMonitorCommandOutputDisplayDocument {
length: displayString.length - previousLength
)
guard suffixRange.length > 0,
- NSIntersectionRange(displayBlock.range, suffixRange).length > 0
+ NSIntersectionRange(displayBlock.range, suffixRange).length > 0
else {
return nil
}
@@ -935,10 +965,10 @@ enum ReviewMonitorCommandOutputDisplayDocument {
displayBlocks: [ReviewMonitorLog.Block]
) -> NSRange? {
guard let sourceBlock = sourceBlocks.first(where: { $0.id == blockID }),
- sourceBlock.kind != .command,
- sourceBlock.kind != .commandOutput,
- let displayBlock = displayBlocks.first(where: { $0.id == blockID }),
- NSMaxRange(range) <= NSMaxRange(sourceBlock.range)
+ sourceBlock.kind != .command,
+ sourceBlock.kind != .commandOutput,
+ let displayBlock = displayBlocks.first(where: { $0.id == blockID }),
+ NSMaxRange(range) <= NSMaxRange(sourceBlock.range)
else {
return nil
}
@@ -949,3 +979,9 @@ enum ReviewMonitorCommandOutputDisplayDocument {
return mappedRange
}
}
+
+private extension String {
+ var nilIfEmpty: String? {
+ isEmpty ? nil : self
+ }
+}
diff --git a/Sources/ReviewUI/Detail/ReviewMonitorLogCommandOutputPanelView.swift b/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogCommandOutputPanelView.swift
similarity index 95%
rename from Sources/ReviewUI/Detail/ReviewMonitorLogCommandOutputPanelView.swift
rename to Sources/ReviewChatLogUI/Detail/ReviewMonitorLogCommandOutputPanelView.swift
index a3485b80..aab4f7e9 100644
--- a/Sources/ReviewUI/Detail/ReviewMonitorLogCommandOutputPanelView.swift
+++ b/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogCommandOutputPanelView.swift
@@ -498,14 +498,17 @@ final class ReviewMonitorCommandOutputPanelAttachment: NSTextAttachment {
let blockID: ReviewMonitorLog.BlockID
let panel: ReviewMonitorLog.CommandOutputPanel
let outputLineHeight: CGFloat
+ let restoredOutputScrollOffset: CGFloat?
init(
panel: ReviewMonitorLog.CommandOutputPanel,
- outputLineHeight: CGFloat
+ outputLineHeight: CGFloat,
+ restoredOutputScrollOffset: CGFloat? = nil
) {
self.blockID = panel.blockID
self.panel = panel
self.outputLineHeight = outputLineHeight
+ self.restoredOutputScrollOffset = restoredOutputScrollOffset
super.init(data: nil, ofType: nil)
allowsTextAttachmentView = true
@@ -711,7 +714,10 @@ final class ReviewMonitorCommandOutputPanelAttachmentView: NSView {
attachment: ReviewMonitorCommandOutputPanelAttachment,
backgroundAlpha: CGFloat
) {
- let panelChanged = panelView.configure(attachment.panel)
+ let panelChanged = panelView.configure(
+ attachment.panel,
+ restoredOutputScrollOffset: attachment.restoredOutputScrollOffset
+ )
let blockIDChanged = blockID != attachment.blockID
let backgroundChanged = abs(self.backgroundAlpha - backgroundAlpha) > 0.001
guard panelChanged || blockIDChanged || backgroundChanged else {
@@ -774,6 +780,11 @@ final class ReviewMonitorCommandOutputPanelAttachmentView: NSView {
panelView.drawTerminalCharacters(in: range, forContentView: view)
}
+ var outputScrollRestoreOffset: CGFloat? {
+ prepareForRangeQueries()
+ return panelView.outputScrollRestoreOffset
+ }
+
private func prepareForRangeQueries() {
layoutSubtreeIfNeeded()
}
@@ -878,6 +889,7 @@ final class ReviewMonitorCommandOutputPanelView: NSView {
private var outputLineCount = 0
private var outputMaximumLineUTF16Length = 0
private var shouldScrollOutputToBottomOnNextLayout = false
+ private var pendingOutputScrollOffset: CGFloat?
override var isFlipped: Bool {
true
@@ -929,8 +941,17 @@ final class ReviewMonitorCommandOutputPanelView: NSView {
}
@discardableResult
- func configure(_ panel: ReviewMonitorLog.CommandOutputPanel) -> Bool {
+ func configure(
+ _ panel: ReviewMonitorLog.CommandOutputPanel,
+ restoredOutputScrollOffset: CGFloat? = nil
+ ) -> Bool {
guard self.panel != panel else {
+ if let restoredOutputScrollOffset, panel.isExpanded {
+ pendingOutputScrollOffset = restoredOutputScrollOffset
+ shouldScrollOutputToBottomOnNextLayout = false
+ needsLayout = true
+ return true
+ }
return false
}
@@ -946,7 +967,10 @@ final class ReviewMonitorCommandOutputPanelView: NSView {
let outputChanged = previousPanel?.outputText != panel.outputText ||
outputText != panel.outputText
let shouldFollowOutput = wasExpanded == false || outputScrollIsPinnedToBottom()
- if isExpanded, wasExpanded == false || (outputChanged && shouldFollowOutput) {
+ if let restoredOutputScrollOffset, isExpanded {
+ pendingOutputScrollOffset = restoredOutputScrollOffset
+ shouldScrollOutputToBottomOnNextLayout = false
+ } else if isExpanded, wasExpanded == false || (outputChanged && shouldFollowOutput) {
shouldScrollOutputToBottomOnNextLayout = true
}
@@ -1075,6 +1099,7 @@ final class ReviewMonitorCommandOutputPanelView: NSView {
width: resultWidth,
height: footerHeight
)
+ restorePendingOutputScrollOffsetIfNeeded()
scrollOutputToBottomIfNeeded()
}
@@ -1157,6 +1182,32 @@ final class ReviewMonitorCommandOutputPanelView: NSView {
return abs(maxY - clipView.bounds.origin.y) <= 0.5
}
+ var outputScrollRestoreOffset: CGFloat? {
+ guard panel?.isExpanded == true,
+ outputScrollView.isHidden == false,
+ outputScrollIsPinnedToBottom() == false
+ else {
+ return nil
+ }
+ return outputScrollView.contentView.bounds.origin.y
+ }
+
+ private func restorePendingOutputScrollOffsetIfNeeded() {
+ guard let pendingOutputScrollOffset,
+ outputScrollView.isHidden == false,
+ outputScrollView.contentSize.height > 0
+ else {
+ return
+ }
+
+ let clipView = outputScrollView.contentView
+ let maxY = max(0, outputTextView.frame.height - clipView.bounds.height)
+ let nextY = min(max(0, pendingOutputScrollOffset), maxY)
+ clipView.scroll(to: NSPoint(x: clipView.bounds.origin.x, y: nextY))
+ outputScrollView.reflectScrolledClipView(clipView)
+ self.pendingOutputScrollOffset = nil
+ }
+
private func scrollOutputToBottomIfNeeded() {
guard shouldScrollOutputToBottomOnNextLayout,
outputScrollView.isHidden == false,
diff --git a/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogDocumentProjection.swift b/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogDocumentProjection.swift
new file mode 100644
index 00000000..4f64cd3d
--- /dev/null
+++ b/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogDocumentProjection.swift
@@ -0,0 +1,266 @@
+import Foundation
+
+struct ReviewMonitorLogProjectedBlock: Equatable, Sendable {
+ var id: ReviewMonitorLog.BlockID
+ var kind: ReviewMonitorLog.Kind
+ var groupID: String?
+ var text: String
+ var metadata: ReviewMonitorLog.Metadata?
+}
+
+struct ReviewMonitorLogDocumentProjection: Sendable {
+ private var document = ReviewMonitorLog.Document()
+
+ var currentDocument: ReviewMonitorLog.Document {
+ document
+ }
+
+ mutating func reset() {
+ document = ReviewMonitorLog.Document()
+ }
+
+ mutating func render(projectedBlocks: [ReviewMonitorLogProjectedBlock]) -> ReviewMonitorLog.Document {
+ let previous = document
+ var current = Self.makeDocument(from: projectedBlocks)
+
+ guard Self.contentChanged(previous: previous, current: current) else {
+ return document
+ }
+
+ current.revision = previous.revision &+ 1
+ current.lastChange = Self.preferredChange(previous: previous, current: current)
+ document = current
+ return document
+ }
+
+ private static func makeDocument(
+ from projectedBlocks: [ReviewMonitorLogProjectedBlock]
+ ) -> ReviewMonitorLog.Document {
+ var builder = DocumentBuilder()
+ for projectedBlock in projectedBlocks {
+ builder.append(projectedBlock)
+ }
+ return builder.document
+ }
+
+ private static func preferredChange(
+ previous: ReviewMonitorLog.Document,
+ current: ReviewMonitorLog.Document
+ ) -> ReviewMonitorLog.Change {
+ if let append = appendChange(previous: previous, current: current) {
+ return .append(append)
+ }
+ if let replacement = replacementChange(previous: previous, current: current) {
+ return .replace(replacement)
+ }
+ return .reload
+ }
+
+ private static func appendChange(
+ previous: ReviewMonitorLog.Document,
+ current: ReviewMonitorLog.Document
+ ) -> ReviewMonitorLog.Append? {
+ guard current.textUTF16Length > previous.textUTF16Length,
+ current.text.hasPrefix(previous.text)
+ else {
+ return nil
+ }
+
+ let suffix = String(current.text.dropFirst(previous.text.count))
+ let suffixLength = utf16Length(suffix)
+ let suffixRange = NSRange(location: previous.textUTF16Length, length: suffixLength)
+ let block = current.blocks.first {
+ NSIntersectionRange($0.range, suffixRange).length > 0
+ }
+ guard
+ existingPresentationUnchanged(
+ previous: previous,
+ current: current,
+ suffixBlockID: block?.id
+ )
+ else {
+ return nil
+ }
+ return .init(
+ kind: block?.kind ?? .event,
+ blockID: block?.id ?? ReviewMonitorLog.BlockID("logAppend"),
+ range: suffixRange,
+ text: suffix,
+ textUTF16Length: suffixLength,
+ animationSpans: current.blocks.flatMap { block in
+ let intersection = NSIntersectionRange(block.range, suffixRange)
+ guard intersection.length > 0 else {
+ return [] as [ReviewMonitorLog.AnimationSpan]
+ }
+ return ReviewMonitorLog.Append.animationSpans(
+ forKind: block.kind,
+ absoluteRange: intersection,
+ appendBaseLocation: previous.textUTF16Length
+ )
+ }
+ )
+ }
+
+ private static func existingPresentationUnchanged(
+ previous: ReviewMonitorLog.Document,
+ current: ReviewMonitorLog.Document,
+ suffixBlockID: ReviewMonitorLog.BlockID?
+ ) -> Bool {
+ var currentBlocksByID = [ReviewMonitorLog.BlockID: ReviewMonitorLog.Block]()
+ for currentBlock in current.blocks {
+ currentBlocksByID[currentBlock.id] = currentBlock
+ }
+
+ for previousBlock in previous.blocks {
+ guard let currentBlock = currentBlocksByID[previousBlock.id] else {
+ return false
+ }
+ if previousBlock.id == suffixBlockID {
+ guard currentBlock.kind == previousBlock.kind,
+ currentBlock.groupID == previousBlock.groupID,
+ currentBlock.range.location == previousBlock.range.location,
+ currentBlock.sourceRange.location == previousBlock.sourceRange.location,
+ currentBlock.metadata == previousBlock.metadata,
+ currentBlock.range.length >= previousBlock.range.length,
+ currentBlock.sourceRange.length >= previousBlock.sourceRange.length
+ else {
+ return false
+ }
+ } else if currentBlock != previousBlock {
+ return false
+ }
+ }
+ return true
+ }
+
+ private static func replacementChange(
+ previous: ReviewMonitorLog.Document,
+ current: ReviewMonitorLog.Document
+ ) -> ReviewMonitorLog.Replacement? {
+ for previousBlock in previous.blocks {
+ guard let currentBlock = current.blocks.first(where: { $0.id == previousBlock.id }),
+ currentBlock.range.location == previousBlock.range.location,
+ NSMaxRange(currentBlock.range) <= current.textUTF16Length
+ else {
+ continue
+ }
+
+ let replacementText = (current.text as NSString).substring(with: currentBlock.range)
+ let candidate = replacingText(
+ in: previous.text,
+ range: previousBlock.range,
+ with: replacementText
+ )
+ guard candidate == current.text else {
+ continue
+ }
+ return .init(
+ kind: currentBlock.kind,
+ blockID: currentBlock.id,
+ range: previousBlock.range,
+ text: replacementText,
+ textUTF16Length: currentBlock.range.length
+ )
+ }
+ return nil
+ }
+
+ private static func replacingText(
+ in text: String,
+ range: NSRange,
+ with replacement: String
+ ) -> String {
+ let string = text as NSString
+ let prefix = string.substring(with: NSRange(location: 0, length: range.location))
+ let suffixLocation = NSMaxRange(range)
+ let suffix = string.substring(
+ with: NSRange(location: suffixLocation, length: string.length - suffixLocation)
+ )
+ return prefix + replacement + suffix
+ }
+
+ private static func contentChanged(
+ previous: ReviewMonitorLog.Document,
+ current: ReviewMonitorLog.Document
+ ) -> Bool {
+ previous.text != current.text || previous.sourceText != current.sourceText || previous.blocks != current.blocks
+ || previous.styleRuns != current.styleRuns || previous.decorations != current.decorations
+ }
+
+ private static func utf16Length(_ text: String) -> Int {
+ (text as NSString).length
+ }
+
+ private struct DocumentBuilder {
+ private(set) var document = ReviewMonitorLog.Document()
+ private var hasVisibleSections = false
+
+ mutating func append(_ block: ReviewMonitorLogProjectedBlock) {
+ guard Self.isVisible(kind: block.kind, text: block.text) else {
+ return
+ }
+
+ let renderedText = ReviewMonitorLogStyler.renderedText(
+ for: block.kind,
+ source: block.text,
+ blockID: block.id
+ )
+ let appended = appendedText(renderedText, after: document.text)
+ let appendedSource = appendedText(block.text, after: document.sourceText)
+ hasVisibleSections = true
+
+ let previousLength = document.textUTF16Length
+ let previousSourceLength = document.sourceTextUTF16Length
+ let suffixLength = ReviewMonitorLogDocumentProjection.utf16Length(appended)
+ let sourceSuffixLength = ReviewMonitorLogDocumentProjection.utf16Length(appendedSource)
+ let blockLength = ReviewMonitorLogDocumentProjection.utf16Length(renderedText)
+ let sourceBlockLength = ReviewMonitorLogDocumentProjection.utf16Length(block.text)
+ let blockRange = NSRange(
+ location: previousLength + max(0, suffixLength - blockLength),
+ length: blockLength
+ )
+ let sourceBlockRange = NSRange(
+ location: previousSourceLength + max(0, sourceSuffixLength - sourceBlockLength),
+ length: sourceBlockLength
+ )
+
+ document.text += appended
+ document.textUTF16Length += suffixLength
+ document.sourceText += appendedSource
+ document.sourceTextUTF16Length += sourceSuffixLength
+ let logBlock = ReviewMonitorLog.Block(
+ id: block.id,
+ kind: block.kind,
+ groupID: block.groupID,
+ range: blockRange,
+ sourceRange: sourceBlockRange,
+ metadata: block.metadata
+ )
+ document.blocks.append(logBlock)
+ ReviewMonitorLogStyler.appendPresentation(for: logBlock, to: &document)
+ }
+
+ private func appendedText(_ blockText: String, after existingText: String) -> String {
+ guard hasVisibleSections else {
+ return blockText
+ }
+ if blockText.isEmpty {
+ return "\n\n"
+ }
+ if existingText.hasSuffix("\n\n") {
+ return blockText
+ }
+ if existingText.hasSuffix("\n") || blockText.hasPrefix("\n") {
+ return "\n" + blockText
+ }
+ return "\n\n" + blockText
+ }
+
+ private static func isVisible(kind: ReviewMonitorLog.Kind, text: String) -> Bool {
+ if kind == .diagnostic {
+ return true
+ }
+ return text.isEmpty == false
+ }
+ }
+}
diff --git a/Sources/ReviewUI/Detail/ReviewMonitorLogDocumentView.swift b/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogDocumentView.swift
similarity index 78%
rename from Sources/ReviewUI/Detail/ReviewMonitorLogDocumentView.swift
rename to Sources/ReviewChatLogUI/Detail/ReviewMonitorLogDocumentView.swift
index ce777f86..c6670826 100644
--- a/Sources/ReviewUI/Detail/ReviewMonitorLogDocumentView.swift
+++ b/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogDocumentView.swift
@@ -1,8 +1,7 @@
import AppKit
-import CodexReview
import QuartzCore
-private extension ReviewLogEntry.Kind {
+private extension ReviewMonitorLog.Kind {
var requiresMarkdownPresentationInvalidationOnAppend: Bool {
switch self {
case .agentMessage, .plan, .reasoning, .reasoningSummary, .rawReasoning:
@@ -14,7 +13,9 @@ private extension ReviewLogEntry.Kind {
}
@MainActor
-final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @preconcurrency NSTextViewportLayoutControllerDelegate {
+final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations,
+ @preconcurrency NSTextViewportLayoutControllerDelegate
+{
private let textContentStorage = NSTextContentStorage()
private let textLayoutManager = NSTextLayoutManager()
private let textContainer = NSTextContainer(
@@ -43,6 +44,7 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
private var currentDecorations: [ReviewMonitorLog.Decoration] = []
private var currentCommandOutputPanels: [ReviewMonitorLog.CommandOutputPanel] = []
private var expandedCommandOutputBlockIDs = Set()
+ private var commandOutputPanelOutputScrollOffsetsByBlockID: [ReviewMonitorLog.BlockID: CGFloat] = [:]
private var cachedFinderStringMapping: FinderStringMapping?
private(set) var estimatedDocumentHeight: CGFloat = 0
private var glowTimer: Timer?
@@ -51,10 +53,10 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
var onLayoutInvalidated: (() -> Void)?
var onUserSelectionChanged: (() -> Void)?
var onCommandOutputPanelToggle: ((ReviewMonitorLog.BlockID) -> Void)?
-#if DEBUG
- var reduceMotionOverrideForTesting: Bool?
- private var wordFadeDisplayInvalidationCount = 0
-#endif
+ #if DEBUG
+ var reduceMotionOverrideForTesting: Bool?
+ private var wordFadeDisplayInvalidationCount = 0
+ #endif
private let baseFont = NSFont.systemFont(
ofSize: NSFont.preferredFont(forTextStyle: .body).pointSize
@@ -380,7 +382,7 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
replacement: ReviewMonitorLog.Replacement? = nil
) {
guard document.textUTF16Length == textStorage.length,
- append != nil || replacement != nil || document.text == textStorage.string
+ append != nil || replacement != nil || document.text == textStorage.string
else {
currentDecorations = []
currentCommandOutputPanels = []
@@ -390,6 +392,7 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
return
}
+ cacheCommandOutputPanelOutputScrollOffsets()
let commandOutputPanels = displayPanelsWithCurrentExpansionState(document.commandOutputPanels)
let changedPanelRanges = changedCommandOutputPanelRanges(
from: currentCommandOutputPanels,
@@ -397,16 +400,19 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
)
currentDecorations = document.decorations
currentCommandOutputPanels = commandOutputPanels
+ pruneCommandOutputPanelOutputScrollOffsets(for: commandOutputPanels)
invalidateFinderStringMapping()
if let append,
- let invalidationRange = styleInvalidationRange(for: append, in: document) {
+ let invalidationRange = styleInvalidationRange(for: append, in: document)
+ {
applyStyleRuns(
document.styleRuns,
commandOutputPanels: commandOutputPanels,
in: [invalidationRange] + changedPanelRanges
)
} else if let replacement,
- let invalidationRange = styleInvalidationRange(for: replacement, in: document) {
+ let invalidationRange = styleInvalidationRange(for: replacement, in: document)
+ {
applyStyleRuns(
document.styleRuns,
commandOutputPanels: commandOutputPanels,
@@ -425,6 +431,7 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
func resetCommandOutputPanelState() {
expandedCommandOutputBlockIDs.removeAll()
+ commandOutputPanelOutputScrollOffsetsByBlockID.removeAll()
currentCommandOutputPanels = displayPanelsWithCurrentExpansionState(currentCommandOutputPanels)
invalidateFinderStringMapping()
}
@@ -432,6 +439,8 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
func pruneCommandOutputPanelExpansionState(for panels: [ReviewMonitorLog.CommandOutputPanel]) {
let blockIDs = Set(panels.map(\.blockID))
expandedCommandOutputBlockIDs.formIntersection(blockIDs)
+ commandOutputPanelOutputScrollOffsetsByBlockID = commandOutputPanelOutputScrollOffsetsByBlockID
+ .filter { blockIDs.contains($0.key) }
}
@discardableResult
@@ -556,8 +565,9 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
textContentStorage.performEditingTransaction {
if let panelAttachmentRange = commandOutputPanelAttachmentRange(for: panel),
- NSIntersectionRange(panelAttachmentRange, targetRange).length == panelAttachmentRange.length,
- panelAttachmentRange.location < textStorage.length {
+ NSIntersectionRange(panelAttachmentRange, targetRange).length == panelAttachmentRange.length,
+ panelAttachmentRange.location < textStorage.length
+ {
textStorage.setAttributes(baseAttributes, range: panelAttachmentRange)
}
textStorage.removeAttribute(.attachment, range: targetRange)
@@ -566,7 +576,8 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
}
private func normalizedRanges(_ ranges: [NSRange], in fullRange: NSRange) -> [NSRange] {
- let clippedRanges = ranges
+ let clippedRanges =
+ ranges
.map { NSIntersectionRange($0, fullRange) }
.filter { $0.length > 0 }
.sorted { $0.location < $1.location }
@@ -595,8 +606,8 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
for panel in panels {
let attachmentRange = NSRange(location: panel.range.location, length: min(1, panel.range.length))
guard NSIntersectionRange(attachmentRange, targetRange).length == attachmentRange.length,
- attachmentRange.length == 1,
- attachmentRange.location < textStorage.length
+ attachmentRange.length == 1,
+ attachmentRange.location < textStorage.length
else {
continue
}
@@ -611,10 +622,11 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
)
if panel.isActive,
- let startedAt = panel.startedAt,
- let timerAttachmentRange = commandOutputTimerAttachmentRange(for: panel),
- NSIntersectionRange(timerAttachmentRange, targetRange).length == timerAttachmentRange.length,
- timerAttachmentRange.location < textStorage.length {
+ let startedAt = panel.startedAt,
+ let timerAttachmentRange = commandOutputTimerAttachmentRange(for: panel),
+ NSIntersectionRange(timerAttachmentRange, targetRange).length == timerAttachmentRange.length,
+ timerAttachmentRange.location < textStorage.length
+ {
textStorage.addAttribute(
.attachment,
value: ReviewMonitorCommandOutputTimerAttachment(
@@ -627,9 +639,9 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
}
guard let panelAttachmentRange = commandOutputPanelAttachmentRange(for: panel),
- panelAttachmentRange.location >= panel.range.location,
- NSIntersectionRange(panelAttachmentRange, targetRange).length == panelAttachmentRange.length,
- panelAttachmentRange.location < textStorage.length
+ panelAttachmentRange.location >= panel.range.location,
+ NSIntersectionRange(panelAttachmentRange, targetRange).length == panelAttachmentRange.length,
+ panelAttachmentRange.location < textStorage.length
else {
continue
}
@@ -637,7 +649,8 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
.attachment,
value: ReviewMonitorCommandOutputPanelAttachment(
panel: commandOutputPanelAttachmentPayload(for: panel),
- outputLineHeight: commandOutputPanelLineHeight
+ outputLineHeight: commandOutputPanelLineHeight,
+ restoredOutputScrollOffset: commandOutputPanelOutputScrollOffsetsByBlockID[panel.blockID]
),
range: panelAttachmentRange
)
@@ -675,17 +688,18 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
guard panel.startedAt != nil else {
return nil
}
- let location = panel.range.location
+ let location =
+ panel.range.location
+ (ReviewMonitorCommandOutputDisplayDocument.toggleAttachmentCharacter + panel.title).utf16.count
return NSRange(location: location, length: 1)
}
private func commandOutputHeaderUTF16Length(for panel: ReviewMonitorLog.CommandOutputPanel) -> Int {
- (
- ReviewMonitorCommandOutputDisplayDocument.toggleAttachmentCharacter
- + panel.title
- + (panel.isActive && panel.startedAt != nil ? ReviewMonitorCommandOutputDisplayDocument.toggleAttachmentCharacter : "")
- ).utf16.count
+ (ReviewMonitorCommandOutputDisplayDocument.toggleAttachmentCharacter
+ + panel.title
+ + (panel.isActive && panel.startedAt != nil
+ ? ReviewMonitorCommandOutputDisplayDocument.toggleAttachmentCharacter : ""))
+ .utf16.count
}
private func styleInvalidationRange(
@@ -808,20 +822,20 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
}
private var shouldAnimateGlow: Bool {
-#if DEBUG
- if let reduceMotionOverrideForTesting {
- return reduceMotionOverrideForTesting == false
- }
-#endif
+ #if DEBUG
+ if let reduceMotionOverrideForTesting {
+ return reduceMotionOverrideForTesting == false
+ }
+ #endif
return NSWorkspace.shared.accessibilityDisplayShouldReduceMotion == false
}
var shouldAnimateCommandOutputPanelTransitions: Bool {
-#if DEBUG
- if let reduceMotionOverrideForTesting {
- return reduceMotionOverrideForTesting == false
- }
-#endif
+ #if DEBUG
+ if let reduceMotionOverrideForTesting {
+ return reduceMotionOverrideForTesting == false
+ }
+ #endif
return NSWorkspace.shared.accessibilityDisplayShouldReduceMotion == false
}
@@ -863,12 +877,12 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
for (index, fragmentView) in orderedFragmentViews.enumerated() {
let finalFrame = fragmentView.frame
let range = nsRange(for: fragmentView.layoutFragment.rangeInElement)
- let fallbackFrame = index < animation.orderedFragmentFrames.count
+ let fallbackFrame =
+ index < animation.orderedFragmentFrames.count
? animation.orderedFragmentFrames[index]
: nil
- guard let initialFrame = animation.fragmentFramesByRange[FragmentRangeKey(range)] ??
- fallbackFrame,
- verticalFrameComponentsAreNearlyEqual(initialFrame, finalFrame) == false
+ guard let initialFrame = animation.fragmentFramesByRange[FragmentRangeKey(range)] ?? fallbackFrame,
+ verticalFrameComponentsAreNearlyEqual(initialFrame, finalFrame) == false
else {
continue
}
@@ -884,7 +898,8 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
for panelView in panelViews {
let finalFrame = panelView.frame
let initialHeight: CGFloat = finalFrame.height > 1 ? 1 : max(1, panelView.frame.height)
- panelView.frame = NSRect(x: finalFrame.minX, y: finalFrame.minY, width: finalFrame.width, height: initialHeight)
+ panelView.frame = NSRect(
+ x: finalFrame.minX, y: finalFrame.minY, width: finalFrame.width, height: initialHeight)
panelView.animator().frame = finalFrame
}
}
@@ -902,10 +917,11 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
continue
}
let baseColor = foregroundColor(at: range.location)
- initialColorUpdates.append((
- range,
- wordFadeColor(progress: 0, baseColor: baseColor)
- ))
+ initialColorUpdates.append(
+ (
+ range,
+ wordFadeColor(progress: 0, baseColor: baseColor)
+ ))
wordFadeAnimations.append(
ReviewMonitorLog.WordFadeAnimation(
range: range,
@@ -926,7 +942,7 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
animationSpans: [ReviewMonitorLog.AnimationSpan]
) -> [NSRange] {
guard shouldAnimateGlow,
- animationSpans.isEmpty == false
+ animationSpans.isEmpty == false
else {
return []
}
@@ -958,12 +974,13 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
in: cappedRange,
options: [.byLines, .substringNotRequired, .localized]
) { _, lineRange, _, stop in
- ranges.append(contentsOf: self.lineFadeRanges(
- in: nsString,
- localLineRange: lineRange,
- baseLocation: suffixRange.location,
- limit: Self.maxWordFadeCount - ranges.count
- ))
+ ranges.append(
+ contentsOf: self.lineFadeRanges(
+ in: nsString,
+ localLineRange: lineRange,
+ baseLocation: suffixRange.location,
+ limit: Self.maxWordFadeCount - ranges.count
+ ))
if ranges.count >= Self.maxWordFadeCount {
stop.pointee = true
}
@@ -979,7 +996,7 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
limit: Int
) -> [NSRange] {
guard localLineRange.length > 0,
- limit > 0
+ limit > 0
else {
return []
}
@@ -997,8 +1014,9 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
chunkEnd = NSMaxRange(characterRange)
if let start = chunkStart,
- let end = chunkEnd,
- end - start >= Self.maxLineFadeChunkUTF16Length {
+ let end = chunkEnd,
+ end - start >= Self.maxLineFadeChunkUTF16Length
+ {
ranges.append(NSRange(location: baseLocation + start, length: end - start))
chunkStart = nil
chunkEnd = nil
@@ -1010,9 +1028,10 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
}
if ranges.count < limit,
- let start = chunkStart,
- let end = chunkEnd,
- end > start {
+ let start = chunkStart,
+ let end = chunkEnd,
+ end > start
+ {
ranges.append(NSRange(location: baseLocation + start, length: end - start))
}
return ranges
@@ -1063,13 +1082,14 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
let progress = min(1, max(0, (now - startedAt) / Self.wordFadeDuration))
let step = wordFadeAlphaStep(for: progress)
if step != animation.renderedStep {
- colorUpdates.append((
- animation.range,
- wordFadeColor(
- progress: wordFadeProgress(forAlphaStep: step),
- baseColor: animation.baseColor
- )
- ))
+ colorUpdates.append(
+ (
+ animation.range,
+ wordFadeColor(
+ progress: wordFadeProgress(forAlphaStep: step),
+ baseColor: animation.baseColor
+ )
+ ))
updatedRanges.append(animation.range)
animation.renderedStep = step
}
@@ -1111,7 +1131,7 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
for (range, color) in updates {
let range = clamp(range)
guard range.length > 0,
- let textRange = textRange(for: range)
+ let textRange = textRange(for: range)
else {
continue
}
@@ -1127,7 +1147,7 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
for range in ranges {
let range = clamp(range)
guard range.length > 0,
- let textRange = textRange(for: range)
+ let textRange = textRange(for: range)
else {
continue
}
@@ -1143,9 +1163,9 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
invalidationRange = NSUnionRange(invalidationRange, range)
}
-#if DEBUG
- wordFadeDisplayInvalidationCount += 1
-#endif
+ #if DEBUG
+ wordFadeDisplayInvalidationCount += 1
+ #endif
for fragmentView in visibleFragmentViews {
let fragmentRange = nsRange(for: fragmentView.layoutFragment.rangeInElement)
if NSIntersectionRange(fragmentRange, invalidationRange).length > 0 {
@@ -1156,7 +1176,7 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
private func startGlowTimerIfNeeded() {
guard glowTimer == nil,
- hasActiveGlowAnimations
+ hasActiveGlowAnimations
else {
return
}
@@ -1199,9 +1219,9 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
return
}
let viewportBounds = currentViewportBounds()
- guard force ||
- needsViewportLayout ||
- lastViewportLayoutBounds.map({ rectsAreNearlyEqual($0, viewportBounds) == false }) ?? true
+ guard
+ force || needsViewportLayout
+ || lastViewportLayoutBounds.map({ rectsAreNearlyEqual($0, viewportBounds) == false }) ?? true
else {
return
}
@@ -1272,7 +1292,7 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
}
if let textRange = textRange(for: clampedRange) {
textLayoutManager.textSelections = [
- NSTextSelection([textRange], affinity: .downstream, granularity: .character),
+ NSTextSelection([textRange], affinity: .downstream, granularity: .character)
]
} else {
textLayoutManager.textSelections = []
@@ -1377,8 +1397,8 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
func drawCharacters(in range: NSRange, forContentView view: NSView) {
guard view === finderContentView,
- let textRange = textRange(for: range),
- let context = NSGraphicsContext.current?.cgContext
+ let textRange = textRange(for: range),
+ let context = NSGraphicsContext.current?.cgContext
else {
return
}
@@ -1447,8 +1467,8 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
func appendDocumentRange(_ range: NSRange) {
guard range.length > 0,
- range.location >= 0,
- NSMaxRange(range) <= documentString.length
+ range.location >= 0,
+ NSMaxRange(range) <= documentString.length
else {
return
}
@@ -1508,7 +1528,8 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
let fullRange = NSRange(location: 0, length: (mapping.string as NSString).length)
let intersection = NSIntersectionRange(range, fullRange)
if intersection.location == range.location,
- intersection.length == range.length {
+ intersection.length == range.length
+ {
return range
}
return NSRange(location: min(max(0, range.location), fullRange.length), length: 0)
@@ -1671,7 +1692,9 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
}
private func invalidateTextLayout(measureEstimatedHeightImmediately: Bool) {
- invalidateTextLayout(in: NSRange(location: 0, length: textStorage.length), measureEstimatedHeightImmediately: measureEstimatedHeightImmediately)
+ invalidateTextLayout(
+ in: NSRange(location: 0, length: textStorage.length),
+ measureEstimatedHeightImmediately: measureEstimatedHeightImmediately)
}
private func invalidateTextLayout(
@@ -1727,7 +1750,7 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
private func textReplacementInvalidationStartUTF16Offset(for range: NSRange) -> Int {
let string = textStorage.string as NSString
guard string.length > 0,
- range.location < string.length
+ range.location < string.length
else {
return clampUTF16Offset(range.location)
}
@@ -1738,8 +1761,7 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
private func syncTextContainerSize() -> Bool {
let targetWidth = max(0, preferredTextContainerWidth - textContainerInset.width * 2)
let targetSize = NSSize(width: targetWidth, height: CGFloat.greatestFiniteMagnitude)
- guard abs(textContainer.size.width - targetSize.width) > 0.5 ||
- textContainer.size.height != targetSize.height
+ guard abs(textContainer.size.width - targetSize.width) > 0.5 || textContainer.size.height != targetSize.height
else {
return false
}
@@ -1794,7 +1816,7 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
private func updateSelectionRects() {
guard selectedRange.length > 0,
- let textRange = textRange(for: selectedRange)
+ let textRange = textRange(for: selectedRange)
else {
selectionView.selectionRects = []
return
@@ -1816,7 +1838,7 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
private func updateDecorationRects() {
let visibleDecorations = currentDecorations.filter { decorationNeedsResolvedRect($0.style) }
guard visibleDecorations.isEmpty == false,
- bounds.width > 0
+ bounds.width > 0
else {
decorationView.decorations = []
return
@@ -1853,8 +1875,9 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
private var commandOutputPanelBackgroundAlpha: CGFloat {
let workspace = NSWorkspace.shared
- if workspace.accessibilityDisplayShouldReduceTransparency ||
- workspace.accessibilityDisplayShouldIncreaseContrast {
+ if workspace.accessibilityDisplayShouldReduceTransparency
+ || workspace.accessibilityDisplayShouldIncreaseContrast
+ {
return 1
}
return 0.3
@@ -2031,7 +2054,7 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
let clampedOffset = clampUTF16Offset(offset)
let boundaryOffset = characterBoundaryOffset(at: clampedOffset, rounding: direction)
guard boundaryOffset == clampedOffset,
- let currentIndex = stringIndex(atUTF16Offset: boundaryOffset, in: string)
+ let currentIndex = stringIndex(atUTF16Offset: boundaryOffset, in: string)
else {
return boundaryOffset
}
@@ -2095,7 +2118,7 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
func isWordCharacter(at index: Int) -> Bool {
guard index >= 0, index < length,
- let scalar = UnicodeScalar(string.character(at: index))
+ let scalar = UnicodeScalar(string.character(at: index))
else {
return false
}
@@ -2175,13 +2198,14 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
layoutTextViewport()
textLayoutManager.ensureLayout(for: textRange)
- guard let destinationSelection = textLayoutManager.textSelectionNavigation.destinationSelection(
- for: NSTextSelection([textRange], affinity: .downstream, granularity: .character),
- direction: direction,
- destination: destination,
- extending: false,
- confined: false
- ),
+ guard
+ let destinationSelection = textLayoutManager.textSelectionNavigation.destinationSelection(
+ for: NSTextSelection([textRange], affinity: .downstream, granularity: .character),
+ direction: direction,
+ destination: destination,
+ extending: false,
+ confined: false
+ ),
let destinationRange = destinationSelection.textRanges.first
else {
return nil
@@ -2211,10 +2235,11 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
private func textRange(for range: NSRange) -> NSTextRange? {
let clampedRange = clamp(range)
- guard let startLocation = textContentStorage.location(
- textContentStorage.documentRange.location,
- offsetBy: clampedRange.location
- ),
+ guard
+ let startLocation = textContentStorage.location(
+ textContentStorage.documentRange.location,
+ offsetBy: clampedRange.location
+ ),
let endLocation = textContentStorage.location(
startLocation,
offsetBy: clampedRange.length
@@ -2315,14 +2340,8 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
}
private func isUsableViewportRect(_ rect: NSRect) -> Bool {
- rect.minX.isFinite &&
- rect.minY.isFinite &&
- rect.width.isFinite &&
- rect.height.isFinite &&
- abs(rect.minX) < 1_000_000_000 &&
- abs(rect.minY) < 1_000_000_000 &&
- rect.width >= 0 &&
- rect.height >= 0
+ rect.minX.isFinite && rect.minY.isFinite && rect.width.isFinite && rect.height.isFinite
+ && abs(rect.minX) < 1_000_000_000 && abs(rect.minY) < 1_000_000_000 && rect.width >= 0 && rect.height >= 0
}
func textViewportLayoutControllerWillLayout(_ textViewportLayoutController: NSTextViewportLayoutController) {
@@ -2363,7 +2382,8 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
fragmentNeedsImmediateDisplay = true
}
- let attachmentViewProviders = textLayoutFragment.textAttachmentViewProviders.isEmpty
+ let attachmentViewProviders =
+ textLayoutFragment.textAttachmentViewProviders.isEmpty
? commandOutputAttachmentViewProviders(in: textLayoutFragment, parentView: fragmentView)
: textLayoutFragment.textAttachmentViewProviders
fragmentView.syncTextAttachmentViews(
@@ -2388,15 +2408,15 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
var providers: [NSTextAttachmentViewProvider] = []
textStorage.enumerateAttribute(.attachment, in: fragmentRange) { value, range, _ in
guard let attachment = value as? NSTextAttachment,
- (attachment is ReviewMonitorCommandOutputToggleAttachment ||
- attachment is ReviewMonitorCommandOutputTimerAttachment ||
- attachment is ReviewMonitorCommandOutputPanelAttachment),
- let location = textLocation(forUTF16Offset: range.location),
- let provider = attachment.viewProvider(
- for: parentView,
- location: location,
- textContainer: textContainer
- )
+ (attachment is ReviewMonitorCommandOutputToggleAttachment
+ || attachment is ReviewMonitorCommandOutputTimerAttachment
+ || attachment is ReviewMonitorCommandOutputPanelAttachment),
+ let location = textLocation(forUTF16Offset: range.location),
+ let provider = attachment.viewProvider(
+ for: parentView,
+ location: location,
+ textContainer: textContainer
+ )
else {
return
}
@@ -2424,20 +2444,16 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
}
private func rectsAreNearlyEqual(_ lhs: NSRect, _ rhs: NSRect) -> Bool {
- abs(lhs.minX - rhs.minX) <= 0.5 &&
- abs(lhs.minY - rhs.minY) <= 0.5 &&
- abs(lhs.width - rhs.width) <= 0.5 &&
- abs(lhs.height - rhs.height) <= 0.5
+ abs(lhs.minX - rhs.minX) <= 0.5 && abs(lhs.minY - rhs.minY) <= 0.5 && abs(lhs.width - rhs.width) <= 0.5
+ && abs(lhs.height - rhs.height) <= 0.5
}
private func verticalFrameComponentsAreNearlyEqual(_ lhs: NSRect, _ rhs: NSRect) -> Bool {
- abs(lhs.minY - rhs.minY) <= 0.5 &&
- abs(lhs.height - rhs.height) <= 0.5
+ abs(lhs.minY - rhs.minY) <= 0.5 && abs(lhs.height - rhs.height) <= 0.5
}
private func sizesAreNearlyEqual(_ lhs: NSSize, _ rhs: NSSize) -> Bool {
- abs(lhs.width - rhs.width) <= 0.5 &&
- abs(lhs.height - rhs.height) <= 0.5
+ abs(lhs.width - rhs.width) <= 0.5 && abs(lhs.height - rhs.height) <= 0.5
}
private func commandOutputPanelAttachmentViews() -> [ReviewMonitorCommandOutputPanelAttachmentView] {
@@ -2446,412 +2462,428 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p
}
}
+ private func cacheCommandOutputPanelOutputScrollOffsets() {
+ for panelView in commandOutputPanelAttachmentViews() {
+ if let offset = panelView.outputScrollRestoreOffset {
+ commandOutputPanelOutputScrollOffsetsByBlockID[panelView.blockID] = offset
+ } else {
+ commandOutputPanelOutputScrollOffsetsByBlockID.removeValue(forKey: panelView.blockID)
+ }
+ }
+ }
+
+ private func pruneCommandOutputPanelOutputScrollOffsets(
+ for panels: [ReviewMonitorLog.CommandOutputPanel]
+ ) {
+ let expandedBlockIDs = Set(panels.lazy.filter(\.isExpanded).map(\.blockID))
+ commandOutputPanelOutputScrollOffsetsByBlockID = commandOutputPanelOutputScrollOffsetsByBlockID
+ .filter { expandedBlockIDs.contains($0.key) }
+ }
+
private func visibleCommandOutputPanelAttachmentViews() -> [ReviewMonitorCommandOutputPanelAttachmentView] {
commandOutputPanelAttachmentViews().filter {
- $0.isHidden == false &&
- $0.alphaValue > 0 &&
- $0.frame.width > 1 &&
- $0.frame.height > 1
+ $0.isHidden == false && $0.alphaValue > 0 && $0.frame.width > 1 && $0.frame.height > 1
}
}
private func edgeInsetsAreNearlyEqual(_ lhs: NSEdgeInsets, _ rhs: NSEdgeInsets) -> Bool {
- abs(lhs.top - rhs.top) <= 0.5 &&
- abs(lhs.left - rhs.left) <= 0.5 &&
- abs(lhs.bottom - rhs.bottom) <= 0.5 &&
- abs(lhs.right - rhs.right) <= 0.5
+ abs(lhs.top - rhs.top) <= 0.5 && abs(lhs.left - rhs.left) <= 0.5 && abs(lhs.bottom - rhs.bottom) <= 0.5
+ && abs(lhs.right - rhs.right) <= 0.5
}
}
-
#if DEBUG
-@MainActor
-extension ReviewMonitorLogDocumentView {
- var selectedRangeForTesting: NSRange {
- selectedRange
- }
-
- var usesTextKit2ForTesting: Bool {
- textLayoutManager.textContainer === textContainer &&
- textContentStorage.textLayoutManagers.contains(textLayoutManager)
- }
-
- var hitTestTargetsDocumentViewForTesting: Bool {
- guard bounds.isEmpty == false else {
- return false
+ @MainActor
+ extension ReviewMonitorLogDocumentView {
+ var selectedRangeForTesting: NSRange {
+ selectedRange
}
- syncContentSubviewFrames()
- let point = NSPoint(x: bounds.midX, y: bounds.midY)
- return hitTest(point) === self
- }
-
- var visibleFragmentViewCountForTesting: Int {
- visibleFragmentViews.count
- }
- var commandOutputPanelCountForTesting: Int {
- layoutTextViewport(force: true)
- return currentCommandOutputPanels.count
- }
+ var usesTextKit2ForTesting: Bool {
+ textLayoutManager.textContainer === textContainer
+ && textContentStorage.textLayoutManagers.contains(textLayoutManager)
+ }
- var terminalDecorationRectCountForTesting: Int {
- layoutTextViewport(force: true)
- return decorationView.decorations.filter { decoration in
- if case .terminal = decoration.style {
- return true
+ var hitTestTargetsDocumentViewForTesting: Bool {
+ guard bounds.isEmpty == false else {
+ return false
}
- return false
- }.count
- }
+ syncContentSubviewFrames()
+ let point = NSPoint(x: bounds.midX, y: bounds.midY)
+ return hitTest(point) === self
+ }
- var expandedCommandOutputPanelCountForTesting: Int {
- layoutTextViewport(force: true)
- return currentCommandOutputPanels.filter(\.isExpanded).count
- }
+ var visibleFragmentViewCountForTesting: Int {
+ visibleFragmentViews.count
+ }
- var commandOutputPanelUsesTextKit2ForTesting: Bool {
- layoutTextViewport(force: true)
- let visiblePanelViews = visibleCommandOutputPanelAttachmentViewsForTesting()
- guard visiblePanelViews.isEmpty == false else {
- return false
+ var commandOutputPanelCountForTesting: Int {
+ layoutTextViewport(force: true)
+ return currentCommandOutputPanels.count
}
- return visiblePanelViews.allSatisfy(\.usesTextKit2ForTesting)
- }
- var commandOutputPanelUsesInlineAttachmentForTesting: Bool {
- layoutTextViewport(force: true)
- return firstCommandOutputToggleAttachmentForTesting() != nil
- }
+ var terminalDecorationRectCountForTesting: Int {
+ layoutTextViewport(force: true)
+ return decorationView.decorations.filter { decoration in
+ if case .terminal = decoration.style {
+ return true
+ }
+ return false
+ }.count
+ }
- var commandOutputPanelUsesButtonAttachmentForTesting: Bool {
- layoutTextViewport(force: true)
- return firstCommandOutputToggleButtonForTesting() != nil
- }
+ var expandedCommandOutputPanelCountForTesting: Int {
+ layoutTextViewport(force: true)
+ return currentCommandOutputPanels.filter(\.isExpanded).count
+ }
- var collapsedCommandOutputPanelAttachmentLineHeightForTesting: CGFloat? {
- layoutTextViewport(force: true)
- guard let panel = currentCommandOutputPanels.first(where: { $0.isExpanded == false }) else {
- return nil
+ var commandOutputPanelUsesTextKit2ForTesting: Bool {
+ layoutTextViewport(force: true)
+ let visiblePanelViews = visibleCommandOutputPanelAttachmentViewsForTesting()
+ guard visiblePanelViews.isEmpty == false else {
+ return false
+ }
+ return visiblePanelViews.allSatisfy(\.usesTextKit2ForTesting)
}
- guard let attachmentRange = commandOutputPanelAttachmentRange(for: panel) else {
- return nil
+
+ var commandOutputPanelUsesInlineAttachmentForTesting: Bool {
+ layoutTextViewport(force: true)
+ return firstCommandOutputToggleAttachmentForTesting() != nil
}
- return rects(forCharacterRange: attachmentRange).first?.rectValue.height ?? 0
- }
- var collapsedCommandOutputPanelAttachmentPayloadIsEmptyForTesting: Bool {
- layoutTextViewport(force: true)
- guard let panel = currentCommandOutputPanels.first(where: { $0.isExpanded == false }) else {
- return false
+ var commandOutputPanelUsesButtonAttachmentForTesting: Bool {
+ layoutTextViewport(force: true)
+ return firstCommandOutputToggleButtonForTesting() != nil
}
- guard let attachmentRange = commandOutputPanelAttachmentRange(for: panel) else {
- return true
+
+ var collapsedCommandOutputPanelAttachmentLineHeightForTesting: CGFloat? {
+ layoutTextViewport(force: true)
+ guard let panel = currentCommandOutputPanels.first(where: { $0.isExpanded == false }) else {
+ return nil
+ }
+ guard let attachmentRange = commandOutputPanelAttachmentRange(for: panel) else {
+ return nil
+ }
+ return rects(forCharacterRange: attachmentRange).first?.rectValue.height ?? 0
}
- guard attachmentRange.location < textStorage.length,
- let attachment = textStorage.attribute(
- .attachment,
- at: attachmentRange.location,
- effectiveRange: nil
- ) as? ReviewMonitorCommandOutputPanelAttachment
- else {
- return false
+
+ var collapsedCommandOutputPanelAttachmentPayloadIsEmptyForTesting: Bool {
+ layoutTextViewport(force: true)
+ guard let panel = currentCommandOutputPanels.first(where: { $0.isExpanded == false }) else {
+ return false
+ }
+ guard let attachmentRange = commandOutputPanelAttachmentRange(for: panel) else {
+ return true
+ }
+ guard attachmentRange.location < textStorage.length,
+ let attachment = textStorage.attribute(
+ .attachment,
+ at: attachmentRange.location,
+ effectiveRange: nil
+ ) as? ReviewMonitorCommandOutputPanelAttachment
+ else {
+ return false
+ }
+ return attachment.panel.commandText.isEmpty && attachment.panel.outputText.isEmpty
+ && attachment.panel.lineCount == 0
}
- return attachment.panel.commandText.isEmpty &&
- attachment.panel.outputText.isEmpty &&
- attachment.panel.lineCount == 0
- }
- var commandOutputPanelUsesSystemMaterialBackgroundForTesting: Bool {
- layoutTextViewport(force: true)
- let visiblePanelViews = visibleCommandOutputPanelAttachmentViewsForTesting()
- guard visiblePanelViews.isEmpty == false else {
- return false
+ var commandOutputPanelUsesSystemMaterialBackgroundForTesting: Bool {
+ layoutTextViewport(force: true)
+ let visiblePanelViews = visibleCommandOutputPanelAttachmentViewsForTesting()
+ guard visiblePanelViews.isEmpty == false else {
+ return false
+ }
+ return visiblePanelViews.allSatisfy(\.usesSystemMaterialBackgroundForTesting)
}
- return visiblePanelViews.allSatisfy(\.usesSystemMaterialBackgroundForTesting)
- }
- var commandOutputPanelVisibleLineCapacityForTesting: Int {
- layoutTextViewport(force: true)
- return visibleCommandOutputPanelAttachmentViewsForTesting()
- .map(\.visibleLineCapacityForTesting)
- .max() ?? 0
- }
+ var commandOutputPanelVisibleLineCapacityForTesting: Int {
+ layoutTextViewport(force: true)
+ return visibleCommandOutputPanelAttachmentViewsForTesting()
+ .map(\.visibleLineCapacityForTesting)
+ .max() ?? 0
+ }
- var commandOutputPanelResultTextForTesting: String? {
- layoutTextViewport(force: true)
- return firstVisibleCommandOutputPanelViewForTesting()?.resultTextForTesting
- }
+ var commandOutputPanelResultTextForTesting: String? {
+ layoutTextViewport(force: true)
+ return firstVisibleCommandOutputPanelViewForTesting()?.resultTextForTesting
+ }
- var commandOutputPanelTerminalTextForTesting: String? {
- layoutTextViewport(force: true)
- return firstVisibleCommandOutputPanelViewForTesting()?.terminalTextForTesting
- }
+ var commandOutputPanelTerminalTextForTesting: String? {
+ layoutTextViewport(force: true)
+ return firstVisibleCommandOutputPanelViewForTesting()?.terminalTextForTesting
+ }
- func commandOutputPanelTerminalTextForTesting(blockID: ReviewMonitorLog.BlockID) -> String? {
- layoutTextViewport(force: true)
- return visibleCommandOutputPanelAttachmentViewForTesting(blockID: blockID)?.terminalTextForTesting
- }
+ func commandOutputPanelTerminalTextForTesting(blockID: ReviewMonitorLog.BlockID) -> String? {
+ layoutTextViewport(force: true)
+ return visibleCommandOutputPanelAttachmentViewForTesting(blockID: blockID)?.terminalTextForTesting
+ }
- var commandOutputPanelCommandLineTextForTesting: String? {
- layoutTextViewport(force: true)
- return firstVisibleCommandOutputPanelViewForTesting()?.commandLineTextForTesting
- }
+ var commandOutputPanelCommandLineTextForTesting: String? {
+ layoutTextViewport(force: true)
+ return firstVisibleCommandOutputPanelViewForTesting()?.commandLineTextForTesting
+ }
- var commandOutputPanelOutputScrollTextForTesting: String? {
- layoutTextViewport(force: true)
- return firstVisibleCommandOutputPanelViewForTesting()?.outputScrollTextForTesting
- }
+ var commandOutputPanelOutputScrollTextForTesting: String? {
+ layoutTextViewport(force: true)
+ return firstVisibleCommandOutputPanelViewForTesting()?.outputScrollTextForTesting
+ }
- var commandOutputPanelOutputScrollIsScrollableForTesting: Bool {
- layoutTextViewport(force: true)
- return firstVisibleCommandOutputPanelViewForTesting()?.outputScrollIsScrollableForTesting ?? false
- }
+ var commandOutputPanelOutputScrollIsScrollableForTesting: Bool {
+ layoutTextViewport(force: true)
+ return firstVisibleCommandOutputPanelViewForTesting()?.outputScrollIsScrollableForTesting ?? false
+ }
- var commandOutputPanelOutputScrollUsesHorizontalScrollingForTesting: Bool {
- layoutTextViewport(force: true)
- return firstVisibleCommandOutputPanelViewForTesting()?.outputScrollUsesHorizontalScrollingForTesting ?? false
- }
+ var commandOutputPanelOutputScrollUsesHorizontalScrollingForTesting: Bool {
+ layoutTextViewport(force: true)
+ return firstVisibleCommandOutputPanelViewForTesting()?.outputScrollUsesHorizontalScrollingForTesting
+ ?? false
+ }
- var commandOutputPanelOutputScrollVerticalOffsetForTesting: CGFloat? {
- layoutTextViewport(force: true)
- return firstVisibleCommandOutputPanelViewForTesting()?.outputScrollVerticalOffsetForTesting
- }
+ var commandOutputPanelOutputScrollVerticalOffsetForTesting: CGFloat? {
+ layoutTextViewport(force: true)
+ return firstVisibleCommandOutputPanelViewForTesting()?.outputScrollVerticalOffsetForTesting
+ }
- var commandOutputPanelOutputScrollMaximumVerticalOffsetForTesting: CGFloat? {
- layoutTextViewport(force: true)
- return firstVisibleCommandOutputPanelViewForTesting()?.outputScrollMaximumVerticalOffsetForTesting
- }
+ var commandOutputPanelOutputScrollMaximumVerticalOffsetForTesting: CGFloat? {
+ layoutTextViewport(force: true)
+ return firstVisibleCommandOutputPanelViewForTesting()?.outputScrollMaximumVerticalOffsetForTesting
+ }
- func scrollCommandOutputPanelOutputForTesting(deltaY: CGFloat) -> Bool {
- layoutTextViewport(force: true)
- return firstVisibleCommandOutputPanelViewForTesting()?.scrollOutputForTesting(deltaY: deltaY) ?? false
- }
+ func scrollCommandOutputPanelOutputForTesting(deltaY: CGFloat) -> Bool {
+ layoutTextViewport(force: true)
+ return firstVisibleCommandOutputPanelViewForTesting()?.scrollOutputForTesting(deltaY: deltaY) ?? false
+ }
- var commandOutputPanelOutputHitTestTargetsTextViewForTesting: Bool {
- layoutTextViewport(force: true)
- return firstVisibleCommandOutputPanelViewForTesting()?.outputHitTestTargetsTextViewForTesting ?? false
- }
+ var commandOutputPanelOutputHitTestTargetsTextViewForTesting: Bool {
+ layoutTextViewport(force: true)
+ return firstVisibleCommandOutputPanelViewForTesting()?.outputHitTestTargetsTextViewForTesting ?? false
+ }
- func finderRectsForTesting(_ range: NSRange) -> [NSRect] {
- layoutTextViewport(force: true)
- return rects(forFinderCharacterRange: range).map(\.rectValue)
- }
+ func finderRectsForTesting(_ range: NSRange) -> [NSRect] {
+ layoutTextViewport(force: true)
+ return rects(forFinderCharacterRange: range).map(\.rectValue)
+ }
- var firstCommandOutputPanelRectForTesting: NSRect? {
- layoutTextViewport(force: true)
- guard let panelView = firstVisibleCommandOutputPanelViewForTesting() else {
- return nil
+ var firstCommandOutputPanelRectForTesting: NSRect? {
+ layoutTextViewport(force: true)
+ guard let panelView = firstVisibleCommandOutputPanelViewForTesting() else {
+ return nil
+ }
+ return panelView.convert(panelView.bounds, to: self)
}
- return panelView.convert(panelView.bounds, to: self)
- }
- var commandOutputPanelToggleSymbolNameForTesting: String? {
- layoutTextViewport(force: true)
- return firstCommandOutputToggleAttachmentForTesting()?.symbolName
- }
+ var commandOutputPanelToggleSymbolNameForTesting: String? {
+ layoutTextViewport(force: true)
+ return firstCommandOutputToggleAttachmentForTesting()?.symbolName
+ }
- var commandOutputPanelLeadingAlignmentDeltaForTesting: CGFloat? {
- layoutTextViewport(force: true)
- guard let panel = currentCommandOutputPanels.first,
- let panelView = firstVisibleCommandOutputPanelViewForTesting(),
- let attachmentLeadingX = rects(forCharacterRange: NSRange(location: panel.range.location, length: 1))
- .first?.rectValue.minX
- else {
- return nil
+ var commandOutputPanelLeadingAlignmentDeltaForTesting: CGFloat? {
+ layoutTextViewport(force: true)
+ guard let panel = currentCommandOutputPanels.first,
+ let panelView = firstVisibleCommandOutputPanelViewForTesting(),
+ let attachmentLeadingX = rects(forCharacterRange: NSRange(location: panel.range.location, length: 1))
+ .first?.rectValue.minX
+ else {
+ return nil
+ }
+ let panelLeadingX = panelView.convert(panelView.bounds.origin, to: self).x
+ return panelLeadingX - attachmentLeadingX
}
- let panelLeadingX = panelView.convert(panelView.bounds.origin, to: self).x
- return panelLeadingX - attachmentLeadingX
- }
- var commandOutputPanelChevronSizeDeltaForTesting: CGFloat? {
- layoutTextViewport(force: true)
- guard let attachment = firstCommandOutputToggleAttachmentForTesting() else {
- return nil
+ var commandOutputPanelChevronSizeDeltaForTesting: CGFloat? {
+ layoutTextViewport(force: true)
+ guard let attachment = firstCommandOutputToggleAttachmentForTesting() else {
+ return nil
+ }
+ return attachment.symbolSize - attachment.fontPointSize
}
- return attachment.symbolSize - attachment.fontPointSize
- }
- var commandOutputPanelChevronVerticalAlignmentDeltaForTesting: CGFloat? {
- layoutTextViewport(force: true)
- guard let panel = currentCommandOutputPanels.first,
- panel.range.length > 1,
- let attachmentMidY = rects(forCharacterRange: NSRange(location: panel.range.location, length: 1))
- .first?.rectValue.midY,
- let labelMidY = rects(
- forCharacterRange: NSRange(location: panel.range.location + 1, length: panel.title.utf16.count)
- )
+ var commandOutputPanelChevronVerticalAlignmentDeltaForTesting: CGFloat? {
+ layoutTextViewport(force: true)
+ guard let panel = currentCommandOutputPanels.first,
+ panel.range.length > 1,
+ let attachmentMidY = rects(forCharacterRange: NSRange(location: panel.range.location, length: 1))
+ .first?.rectValue.midY,
+ let labelMidY = rects(
+ forCharacterRange: NSRange(location: panel.range.location + 1, length: panel.title.utf16.count)
+ )
.first?.rectValue.midY
- else {
- return nil
+ else {
+ return nil
+ }
+ return attachmentMidY - labelMidY
}
- return attachmentMidY - labelMidY
- }
- func hitTestTargetsDocumentViewForFirstOccurrenceForTesting(_ text: String) -> Bool {
- layoutTextViewport(force: true)
- let range = (textStorage.string as NSString).range(of: text)
- guard range.location != NSNotFound,
- let rect = rects(forCharacterRange: range).first?.rectValue
- else {
- return false
+ func hitTestTargetsDocumentViewForFirstOccurrenceForTesting(_ text: String) -> Bool {
+ layoutTextViewport(force: true)
+ let range = (textStorage.string as NSString).range(of: text)
+ guard range.location != NSNotFound,
+ let rect = rects(forCharacterRange: range).first?.rectValue
+ else {
+ return false
+ }
+ return hitTest(NSPoint(x: rect.midX, y: rect.midY)) === self
}
- return hitTest(NSPoint(x: rect.midX, y: rect.midY)) === self
- }
- func toggleFirstCommandOutputPanelForTesting() {
- layoutTextViewport(force: true)
- guard let blockID = currentCommandOutputPanels.first?.blockID else {
- return
+ func toggleFirstCommandOutputPanelForTesting() {
+ layoutTextViewport(force: true)
+ guard let blockID = currentCommandOutputPanels.first?.blockID else {
+ return
+ }
+ onCommandOutputPanelToggle?(blockID)
}
- onCommandOutputPanelToggle?(blockID)
- }
- @discardableResult
- func clickFirstCommandOutputPanelHeaderForTesting() -> Bool {
- guard let blockID = currentCommandOutputPanels.first?.blockID else {
- return false
+ @discardableResult
+ func clickFirstCommandOutputPanelHeaderForTesting() -> Bool {
+ guard let blockID = currentCommandOutputPanels.first?.blockID else {
+ return false
+ }
+ return clickCommandOutputPanelHeaderForTesting(blockID: blockID)
+ }
+
+ @discardableResult
+ func clickCommandOutputPanelHeaderForTesting(blockID: ReviewMonitorLog.BlockID) -> Bool {
+ layoutTextViewport(force: true)
+ guard let panel = currentCommandOutputPanels.first(where: { $0.blockID == blockID }),
+ let rect = rects(forCharacterRange: NSRange(location: panel.range.location, length: 1)).first?
+ .rectValue,
+ let event = NSEvent.mouseEvent(
+ with: .leftMouseDown,
+ location: convert(NSPoint(x: rect.midX, y: rect.midY), to: nil),
+ modifierFlags: [],
+ timestamp: 0,
+ windowNumber: window?.windowNumber ?? 0,
+ context: nil,
+ eventNumber: 0,
+ clickCount: 1,
+ pressure: 1
+ )
+ else {
+ return false
+ }
+
+ let documentPoint = convert(event.locationInWindow, from: nil)
+ guard let target = hitTest(documentPoint) else {
+ return false
+ }
+ if let button = target as? ReviewMonitorCommandOutputToggleButton {
+ button.performClick(nil)
+ return true
+ }
+ target.mouseDown(with: event)
+ return true
}
- return clickCommandOutputPanelHeaderForTesting(blockID: blockID)
- }
- @discardableResult
- func clickCommandOutputPanelHeaderForTesting(blockID: ReviewMonitorLog.BlockID) -> Bool {
- layoutTextViewport(force: true)
- guard let panel = currentCommandOutputPanels.first(where: { $0.blockID == blockID }),
- let rect = rects(forCharacterRange: NSRange(location: panel.range.location, length: 1)).first?.rectValue,
- let event = NSEvent.mouseEvent(
- with: .leftMouseDown,
- location: convert(NSPoint(x: rect.midX, y: rect.midY), to: nil),
- modifierFlags: [],
- timestamp: 0,
- windowNumber: window?.windowNumber ?? 0,
- context: nil,
- eventNumber: 0,
- clickCount: 1,
- pressure: 1
- )
- else {
- return false
+ private func firstCommandOutputToggleAttachmentForTesting() -> ReviewMonitorCommandOutputToggleAttachment? {
+ guard let panel = currentCommandOutputPanels.first,
+ panel.range.location < textStorage.length
+ else {
+ return nil
+ }
+ return textStorage.attribute(.attachment, at: panel.range.location, effectiveRange: nil)
+ as? ReviewMonitorCommandOutputToggleAttachment
}
- let documentPoint = convert(event.locationInWindow, from: nil)
- guard let target = hitTest(documentPoint) else {
- return false
+ private func firstCommandOutputToggleButtonForTesting() -> ReviewMonitorCommandOutputToggleButton? {
+ visibleFragmentViews
+ .sorted { $0.frame.minY < $1.frame.minY }
+ .compactMap(\.firstCommandOutputToggleButtonForTesting)
+ .first
}
- if let button = target as? ReviewMonitorCommandOutputToggleButton {
- button.performClick(nil)
- return true
+
+ private func firstVisibleCommandOutputPanelViewForTesting() -> ReviewMonitorCommandOutputPanelAttachmentView? {
+ visibleCommandOutputPanelAttachmentViews()
+ .sorted { lhs, rhs in
+ lhs.convert(lhs.bounds.origin, to: self).y < rhs.convert(rhs.bounds.origin, to: self).y
+ }
+ .first
}
- target.mouseDown(with: event)
- return true
- }
- private func firstCommandOutputToggleAttachmentForTesting() -> ReviewMonitorCommandOutputToggleAttachment? {
- guard let panel = currentCommandOutputPanels.first,
- panel.range.location < textStorage.length
- else {
- return nil
+ private func visibleCommandOutputPanelAttachmentViewForTesting(
+ blockID: ReviewMonitorLog.BlockID
+ ) -> ReviewMonitorCommandOutputPanelAttachmentView? {
+ visibleCommandOutputPanelAttachmentViewsForTesting()
+ .first { $0.blockID == blockID }
}
- return textStorage.attribute(.attachment, at: panel.range.location, effectiveRange: nil)
- as? ReviewMonitorCommandOutputToggleAttachment
- }
- private func firstCommandOutputToggleButtonForTesting() -> ReviewMonitorCommandOutputToggleButton? {
- visibleFragmentViews
- .sorted { $0.frame.minY < $1.frame.minY }
- .compactMap(\.firstCommandOutputToggleButtonForTesting)
- .first
- }
+ private func visibleCommandOutputPanelAttachmentViewsForTesting()
+ -> [ReviewMonitorCommandOutputPanelAttachmentView]
+ {
+ visibleCommandOutputPanelAttachmentViews()
+ }
- private func firstVisibleCommandOutputPanelViewForTesting() -> ReviewMonitorCommandOutputPanelAttachmentView? {
- visibleCommandOutputPanelAttachmentViews()
- .sorted { lhs, rhs in
- lhs.convert(lhs.bounds.origin, to: self).y < rhs.convert(rhs.bounds.origin, to: self).y
+ var wordGlowCountForTesting: Int {
+ wordFadeAnimations.count
+ }
+
+ var wordFadeRenderingAttributeRangeCountForTesting: Int {
+ var count = 0
+ textLayoutManager.enumerateRenderingAttributes(
+ from: textContentStorage.documentRange.location,
+ reverse: false
+ ) { _, attributes, _ in
+ if attributes[.foregroundColor] != nil {
+ count += 1
+ }
+ return true
}
- .first
- }
+ return count
+ }
- private func visibleCommandOutputPanelAttachmentViewForTesting(
- blockID: ReviewMonitorLog.BlockID
- ) -> ReviewMonitorCommandOutputPanelAttachmentView? {
- visibleCommandOutputPanelAttachmentViewsForTesting()
- .first { $0.blockID == blockID }
- }
+ var wordFadeStorageUsesOpaqueTextColorForTesting: Bool {
+ guard textStorage.length > 0 else {
+ return true
+ }
- private func visibleCommandOutputPanelAttachmentViewsForTesting() -> [ReviewMonitorCommandOutputPanelAttachmentView] {
- visibleCommandOutputPanelAttachmentViews()
- }
+ var usesOpaqueTextColor = true
+ textStorage.enumerateAttribute(
+ .foregroundColor,
+ in: NSRange(location: 0, length: textStorage.length)
+ ) { value, _, stop in
+ guard let color = value as? NSColor else {
+ return
+ }
+ if color.alphaComponent < 0.999 {
+ usesOpaqueTextColor = false
+ stop.pointee = true
+ }
+ }
+ return usesOpaqueTextColor
+ }
- var wordGlowCountForTesting: Int {
- wordFadeAnimations.count
- }
+ var wordFadeDisplayInvalidationCountForTesting: Int {
+ wordFadeDisplayInvalidationCount
+ }
- var wordFadeRenderingAttributeRangeCountForTesting: Int {
- var count = 0
- textLayoutManager.enumerateRenderingAttributes(
- from: textContentStorage.documentRange.location,
- reverse: false
- ) { _, attributes, _ in
- if attributes[.foregroundColor] != nil {
- count += 1
- }
- return true
+ func completeWordGlowAnimationsForTesting() {
+ glowTimer?.invalidate()
+ glowTimer = nil
+ updateWordFadeAnimations(at: CACurrentMediaTime())
+ let completionTime =
+ wordFadeAnimations
+ .compactMap(\.startedAt)
+ .max()
+ .map { $0 + Self.wordFadeDuration }
+ ?? CACurrentMediaTime()
+ updateWordFadeAnimations(at: completionTime)
}
- return count
- }
- var wordFadeStorageUsesOpaqueTextColorForTesting: Bool {
- guard textStorage.length > 0 else {
- return true
+ func advanceWordGlowAnimationsAfterInitialDelayForTesting(_ delay: TimeInterval) {
+ updateWordFadeAnimations(at: CACurrentMediaTime() + delay)
}
- var usesOpaqueTextColor = true
- textStorage.enumerateAttribute(
- .foregroundColor,
- in: NSRange(location: 0, length: textStorage.length)
- ) { value, _, stop in
- guard let color = value as? NSColor else {
- return
+ var visibleFragmentBoundsForTesting: NSRect {
+ guard let firstFragmentView = visibleFragmentViews.first else {
+ return .zero
}
- if color.alphaComponent < 0.999 {
- usesOpaqueTextColor = false
- stop.pointee = true
+ return visibleFragmentViews.reduce(firstFragmentView.frame) { bounds, fragmentView in
+ bounds.union(fragmentView.frame)
}
}
- return usesOpaqueTextColor
- }
- var wordFadeDisplayInvalidationCountForTesting: Int {
- wordFadeDisplayInvalidationCount
- }
-
- func completeWordGlowAnimationsForTesting() {
- glowTimer?.invalidate()
- glowTimer = nil
- updateWordFadeAnimations(at: CACurrentMediaTime())
- let completionTime = wordFadeAnimations
- .compactMap(\.startedAt)
- .max()
- .map { $0 + Self.wordFadeDuration }
- ?? CACurrentMediaTime()
- updateWordFadeAnimations(at: completionTime)
- }
-
- func advanceWordGlowAnimationsAfterInitialDelayForTesting(_ delay: TimeInterval) {
- updateWordFadeAnimations(at: CACurrentMediaTime() + delay)
- }
-
- var visibleFragmentBoundsForTesting: NSRect {
- guard let firstFragmentView = visibleFragmentViews.first else {
- return .zero
- }
- return visibleFragmentViews.reduce(firstFragmentView.frame) { bounds, fragmentView in
- bounds.union(fragmentView.frame)
+ var staleFragmentViewCountForTesting: Int {
+ lastUsedFragmentViews.filter { $0.superview != nil }.count
}
}
-
- var staleFragmentViewCountForTesting: Int {
- lastUsedFragmentViews.filter { $0.superview != nil }.count
- }
-}
#endif
diff --git a/Sources/ReviewUI/Detail/ReviewMonitorLogLineCounter.swift b/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogLineCounter.swift
similarity index 100%
rename from Sources/ReviewUI/Detail/ReviewMonitorLogLineCounter.swift
rename to Sources/ReviewChatLogUI/Detail/ReviewMonitorLogLineCounter.swift
diff --git a/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogProjection.swift b/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogProjection.swift
new file mode 100644
index 00000000..31846963
--- /dev/null
+++ b/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogProjection.swift
@@ -0,0 +1,1018 @@
+import Foundation
+
+package enum ReviewMonitorLog {}
+
+package extension ReviewMonitorLog {
+ struct BlockID: Codable, Hashable, Sendable {
+ var rawValue: String
+
+ init(_ rawValue: String) {
+ self.rawValue = rawValue
+ }
+ }
+
+ enum Kind: String, Codable, Sendable, Hashable {
+ case agentMessage
+ case command
+ case commandOutput
+ case plan
+ case todoList
+ case reasoning
+ case reasoningSummary
+ case rawReasoning
+ case toolCall
+ case diagnostic
+ case error
+ case progress
+ case event
+ case contextCompaction
+ }
+
+ struct Metadata: Codable, Sendable, Hashable {
+ struct CommandAction: Codable, Sendable, Hashable {
+ enum Kind: String, Codable, Sendable, Hashable {
+ case read
+ case listFiles
+ case search
+ case unknown
+ }
+
+ let kind: Kind
+ let command: String?
+ let name: String?
+ let path: String?
+ let query: String?
+
+ 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
+ }
+ }
+
+ let sourceType: String
+ let title: String?
+ let status: String?
+ let detail: String?
+ let itemID: String?
+ let command: String?
+ let cwd: String?
+ let exitCode: Int?
+ let startedAt: Date?
+ let completedAt: Date?
+ let durationMs: Int?
+ let commandActions: [CommandAction]?
+ let commandStatus: String?
+ let namespace: String?
+ let server: String?
+ let tool: String?
+ let query: String?
+ let path: String?
+ let resultText: String?
+ let errorText: String?
+
+ init(
+ sourceType: String,
+ title: String? = nil,
+ status: String? = nil,
+ detail: String? = nil,
+ itemID: String? = nil,
+ command: String? = nil,
+ cwd: String? = nil,
+ exitCode: Int? = nil,
+ startedAt: Date? = nil,
+ completedAt: Date? = nil,
+ durationMs: Int? = nil,
+ commandActions: [CommandAction]? = nil,
+ commandStatus: String? = nil,
+ namespace: String? = nil,
+ server: String? = nil,
+ tool: String? = nil,
+ query: String? = nil,
+ path: String? = nil,
+ resultText: String? = nil,
+ errorText: String? = nil
+ ) {
+ self.sourceType = sourceType
+ self.title = title
+ self.status = status
+ self.detail = detail
+ self.itemID = itemID
+ self.command = command
+ self.cwd = cwd
+ self.exitCode = exitCode
+ self.startedAt = startedAt
+ self.completedAt = completedAt
+ self.durationMs = durationMs
+ self.commandActions = commandActions
+ self.commandStatus = commandStatus
+ self.namespace = namespace
+ self.server = server
+ self.tool = tool
+ self.query = query
+ self.path = path
+ self.resultText = resultText
+ self.errorText = errorText
+ }
+ }
+
+ struct Block: Equatable, Sendable {
+ var id: ReviewMonitorLog.BlockID
+ var kind: ReviewMonitorLog.Kind
+ var groupID: String?
+ var range: NSRange
+ var sourceRange: NSRange
+ var metadata: ReviewMonitorLog.Metadata?
+
+ init(
+ id: ReviewMonitorLog.BlockID,
+ kind: ReviewMonitorLog.Kind,
+ groupID: String?,
+ range: NSRange,
+ sourceRange: NSRange? = nil,
+ metadata: ReviewMonitorLog.Metadata? = nil
+ ) {
+ self.id = id
+ self.kind = kind
+ self.groupID = groupID
+ self.range = range
+ self.sourceRange = sourceRange ?? range
+ self.metadata = metadata
+ }
+ }
+
+ enum StatusTone: Hashable, Sendable {
+ case neutral
+ case running
+ case success
+ case warning
+ case failure
+ }
+
+ enum PlanStatus: String, Hashable, Sendable {
+ case pending
+ case inProgress
+ case completed
+ case failed
+ }
+
+ enum TextStyle: Hashable, Sendable {
+ case body
+ case heading(level: Int)
+ case bullet
+ case blockquote
+ case strong
+ case emphasis
+ case link
+ case strikethrough
+ case inlineCode
+ case codeFence
+ case markdownSyntax
+ case command
+ case terminalOutput
+ case commandOutputControl(keepsTrailingContent: Bool)
+ case plan(status: ReviewMonitorLog.PlanStatus?)
+ case tool
+ case diagnostic
+ case error
+ case event
+ case contextCompaction
+ case muted
+ }
+
+ struct TextRun: Equatable, Sendable {
+ var range: NSRange
+ var style: ReviewMonitorLog.TextStyle
+ }
+
+ struct AnimationSpan: Equatable, Sendable {
+ enum Kind: Equatable, Sendable {
+ case wordFade
+ }
+
+ var kind: Kind
+ var range: NSRange
+ }
+
+ enum DecorationStyle: Hashable, Sendable {
+ case transcript
+ case command(tone: ReviewMonitorLog.StatusTone)
+ case terminal(tone: ReviewMonitorLog.StatusTone)
+ case codeBlock
+ case plan(tone: ReviewMonitorLog.StatusTone)
+ case reasoning
+ case tool(tone: ReviewMonitorLog.StatusTone)
+ case diagnostic(tone: ReviewMonitorLog.StatusTone)
+ case error
+ case event
+ case contextCompaction(label: String, isCompleted: Bool)
+ }
+
+ struct Decoration: Equatable, Sendable {
+ var blockID: ReviewMonitorLog.BlockID
+ var range: NSRange
+ var style: ReviewMonitorLog.DecorationStyle
+ }
+
+ struct CommandOutputPanel: Equatable, Sendable {
+ var blockID: ReviewMonitorLog.BlockID
+ var range: NSRange
+ var commandText: String
+ var outputText: String
+ var outputSourceRange: NSRange? = nil
+ var lineCount: Int
+ var isExpanded: Bool
+ var isActive: Bool
+ var startedAt: Date?
+ var title: String
+ var exitText: String?
+ }
+
+ struct Append: Equatable, Sendable {
+ var kind: ReviewMonitorLog.Kind
+ var blockID: ReviewMonitorLog.BlockID
+ var range: NSRange
+ var text: String
+ var textUTF16Length: Int
+ var animationSpans: [ReviewMonitorLog.AnimationSpan]
+
+ init(
+ kind: ReviewMonitorLog.Kind,
+ blockID: ReviewMonitorLog.BlockID,
+ range: NSRange,
+ text: String,
+ textUTF16Length: Int? = nil,
+ animationSpans: [ReviewMonitorLog.AnimationSpan] = []
+ ) {
+ self.kind = kind
+ self.blockID = blockID
+ self.range = range
+ self.text = text
+ self.textUTF16Length = textUTF16Length ?? Self.utf16Length(text)
+ self.animationSpans = animationSpans
+ }
+
+ private static func utf16Length(_ text: String) -> Int {
+ (text as NSString).length
+ }
+
+ static func animationSpans(
+ forKind kind: ReviewMonitorLog.Kind,
+ absoluteRange: NSRange,
+ appendBaseLocation: Int
+ ) -> [ReviewMonitorLog.AnimationSpan] {
+ guard Self.wordFadeKinds.contains(kind),
+ absoluteRange.length > 0,
+ absoluteRange.location >= appendBaseLocation
+ else {
+ return []
+ }
+ return [
+ .init(
+ kind: .wordFade,
+ range: NSRange(
+ location: absoluteRange.location - appendBaseLocation,
+ length: absoluteRange.length
+ )
+ )
+ ]
+ }
+
+ private static let wordFadeKinds: Set = [
+ .reasoning,
+ .reasoningSummary,
+ .rawReasoning,
+ ]
+ }
+
+ struct Replacement: Equatable, Sendable {
+ var kind: ReviewMonitorLog.Kind
+ var blockID: ReviewMonitorLog.BlockID
+ var range: NSRange
+ var text: String
+ var textUTF16Length: Int
+
+ init(
+ kind: ReviewMonitorLog.Kind,
+ blockID: ReviewMonitorLog.BlockID,
+ range: NSRange,
+ text: String,
+ textUTF16Length: Int? = nil
+ ) {
+ self.kind = kind
+ self.blockID = blockID
+ self.range = range
+ self.text = text
+ self.textUTF16Length = textUTF16Length ?? Self.utf16Length(text)
+ }
+
+ private static func utf16Length(_ text: String) -> Int {
+ (text as NSString).length
+ }
+ }
+
+ enum Change: Equatable, Sendable {
+ case reload
+ case append(ReviewMonitorLog.Append)
+ case replace(ReviewMonitorLog.Replacement)
+ }
+
+ struct Document: Equatable, Sendable {
+ var text: String
+ var textUTF16Length: Int
+ var sourceText: String
+ var sourceTextUTF16Length: Int
+ var blocks: [ReviewMonitorLog.Block]
+ var styleRuns: [ReviewMonitorLog.TextRun]
+ var decorations: [ReviewMonitorLog.Decoration]
+ var commandOutputPanels: [ReviewMonitorLog.CommandOutputPanel]
+ var revision: UInt64
+ var lastChange: ReviewMonitorLog.Change
+
+ init(
+ text: String = "",
+ textUTF16Length: Int? = nil,
+ sourceText: String? = nil,
+ sourceTextUTF16Length: Int? = nil,
+ blocks: [ReviewMonitorLog.Block] = [],
+ styleRuns: [ReviewMonitorLog.TextRun] = [],
+ decorations: [ReviewMonitorLog.Decoration] = [],
+ commandOutputPanels: [ReviewMonitorLog.CommandOutputPanel] = [],
+ revision: UInt64 = 0,
+ lastChange: ReviewMonitorLog.Change = .reload
+ ) {
+ self.text = text
+ self.textUTF16Length = textUTF16Length ?? Self.utf16Length(text)
+ self.sourceText = sourceText ?? text
+ self.sourceTextUTF16Length = sourceTextUTF16Length ?? Self.utf16Length(sourceText ?? text)
+ self.blocks = blocks
+ self.styleRuns = styleRuns
+ self.decorations = decorations
+ self.commandOutputPanels = commandOutputPanels
+ self.revision = revision
+ self.lastChange = lastChange
+ }
+
+ private static func utf16Length(_ text: String) -> Int {
+ (text as NSString).length
+ }
+
+ mutating func rebuildPresentation() {
+ styleRuns.removeAll(keepingCapacity: true)
+ decorations.removeAll(keepingCapacity: true)
+ commandOutputPanels.removeAll(keepingCapacity: true)
+ for block in blocks {
+ ReviewMonitorLogStyler.appendPresentation(for: block, to: &self)
+ }
+ }
+
+ mutating func rebuildPresentation(forBlockAt blockIndex: Int) {
+ guard blocks.indices.contains(blockIndex) else {
+ rebuildPresentation()
+ return
+ }
+
+ let block = blocks[blockIndex]
+ styleRuns.removeAll {
+ NSIntersectionRange($0.range, block.range).length > 0
+ }
+ decorations.removeAll {
+ $0.blockID == block.id || NSIntersectionRange($0.range, block.range).length > 0
+ }
+ commandOutputPanels.removeAll {
+ $0.blockID == block.id || NSIntersectionRange($0.range, block.range).length > 0
+ }
+ ReviewMonitorLogStyler.appendPresentation(for: block, to: &self)
+ }
+
+ var finderSupplementSignature: Int {
+ 0
+ }
+ }
+}
+
+enum ReviewMonitorLogStyler {
+ struct Presentation {
+ var text: String
+ var styleRuns: [ReviewMonitorLog.TextRun] = []
+ var decorations: [ReviewMonitorLog.Decoration] = []
+ }
+
+ static func renderedText(
+ for kind: ReviewMonitorLog.Kind,
+ source: String,
+ blockID: ReviewMonitorLog.BlockID
+ ) -> String {
+ presentation(for: kind, source: source, blockID: blockID).text
+ }
+
+ static func appendPresentation(for block: ReviewMonitorLog.Block, to document: inout ReviewMonitorLog.Document) {
+ guard block.range.location >= 0,
+ block.range.length >= 0,
+ NSMaxRange(block.range) <= document.textUTF16Length,
+ block.sourceRange.location >= 0,
+ block.sourceRange.length >= 0,
+ NSMaxRange(block.sourceRange) <= document.sourceTextUTF16Length
+ else {
+ return
+ }
+
+ let source = (document.sourceText as NSString).substring(with: block.sourceRange)
+ let presentation = presentation(for: block.kind, source: source, blockID: block.id)
+ if block.range.length > 0 {
+ document.styleRuns.append(.init(range: block.range, style: baseTextStyle(for: block.kind)))
+ document.decorations.append(
+ .init(
+ blockID: block.id,
+ range: block.range,
+ style: decorationStyle(
+ for: block.kind,
+ source: source,
+ metadata: block.metadata
+ )
+ ))
+ }
+
+ for run in presentation.styleRuns {
+ let range = offset(run.range, by: block.range.location, limitingTo: block.range.length)
+ guard range.length > 0 else {
+ continue
+ }
+ document.styleRuns.append(.init(range: range, style: run.style))
+ }
+ for decoration in presentation.decorations {
+ let range = offset(decoration.range, by: block.range.location, limitingTo: block.range.length)
+ guard range.length > 0 else {
+ continue
+ }
+ document.decorations.append(
+ .init(
+ blockID: decoration.blockID,
+ range: range,
+ style: decoration.style
+ ))
+ }
+ }
+
+ private static func presentation(
+ for kind: ReviewMonitorLog.Kind,
+ source: String,
+ blockID: ReviewMonitorLog.BlockID
+ ) -> Presentation {
+ switch kind {
+ case .agentMessage, .reasoning, .reasoningSummary, .rawReasoning:
+ return renderMarkdown(source, blockID: blockID)
+ case .plan, .todoList:
+ return renderPlan(source)
+ case .command, .commandOutput, .toolCall, .diagnostic, .error, .progress, .event, .contextCompaction:
+ return .init(text: source)
+ }
+ }
+
+ private static func baseTextStyle(for kind: ReviewMonitorLog.Kind) -> ReviewMonitorLog.TextStyle {
+ switch kind {
+ case .agentMessage:
+ .body
+ case .command:
+ .body
+ case .commandOutput:
+ .terminalOutput
+ case .plan, .todoList:
+ .body
+ case .reasoning, .reasoningSummary, .rawReasoning:
+ .body
+ case .toolCall:
+ .body
+ case .diagnostic, .error:
+ .body
+ case .progress, .event:
+ .event
+ case .contextCompaction:
+ .contextCompaction
+ }
+ }
+
+ private static func decorationStyle(
+ for kind: ReviewMonitorLog.Kind,
+ source: String,
+ metadata: ReviewMonitorLog.Metadata?
+ ) -> ReviewMonitorLog.DecorationStyle {
+ let tone = statusTone(for: metadata)
+ switch kind {
+ case .agentMessage:
+ return .transcript
+ case .command:
+ return .command(tone: tone)
+ case .commandOutput:
+ return .terminal(tone: tone)
+ case .plan, .todoList:
+ return .plan(tone: tone)
+ case .reasoning, .reasoningSummary, .rawReasoning:
+ return .reasoning
+ case .toolCall:
+ return .tool(tone: tone)
+ case .diagnostic:
+ return .diagnostic(tone: tone)
+ case .error:
+ return .error
+ case .progress, .event:
+ return .event
+ case .contextCompaction:
+ return .contextCompaction(
+ label: source,
+ isCompleted: contextCompactionIsCompleted(metadata)
+ )
+ }
+ }
+
+ private static func contextCompactionIsCompleted(_ metadata: ReviewMonitorLog.Metadata?) -> Bool {
+ let normalized = metadata?.status?
+ .lowercased()
+ .replacingOccurrences(of: "_", with: "")
+ .replacingOccurrences(of: "-", with: "")
+ switch normalized {
+ case "completed", "complete", "succeeded", "success":
+ return true
+ case "failed", "failure", "errored", "error", "cancelled", "canceled", "interrupted":
+ return false
+ default:
+ return metadata?.completedAt != nil
+ }
+ }
+
+ private static func statusTone(for metadata: ReviewMonitorLog.Metadata?) -> ReviewMonitorLog.StatusTone {
+ if let exitCode = metadata?.exitCode {
+ return exitCode == 0 ? .success : .failure
+ }
+
+ let normalized = metadata?.status?
+ .lowercased()
+ .replacingOccurrences(of: "_", with: "")
+ .replacingOccurrences(of: "-", with: "")
+ switch normalized {
+ case "started", "running", "inprogress":
+ return .running
+ case "completed", "complete", "succeeded", "success", "passed", "applied":
+ return .success
+ case "failed", "failure", "errored", "error", "cancelled", "canceled", "interrupted":
+ return .failure
+ case "warning", "warn", "updated":
+ return .warning
+ default:
+ return .neutral
+ }
+ }
+
+ private static func renderMarkdown(
+ _ source: String,
+ blockID: ReviewMonitorLog.BlockID
+ ) -> Presentation {
+ guard containsMarkdownSyntax(in: source) else {
+ return .init(text: source)
+ }
+
+ var options = AttributedString.MarkdownParsingOptions()
+ options.interpretedSyntax = .full
+ options.failurePolicy = .returnPartiallyParsedIfPossible
+
+ guard let attributed = try? AttributedString(markdown: source, options: options) else {
+ return .init(text: source)
+ }
+
+ var builder = MarkdownPresentationBuilder(blockID: blockID)
+ for run in attributed.runs {
+ let text = String(attributed[run.range].characters)
+ let context = MarkdownContext(presentationIntent: run.presentationIntent)
+ builder.append(text, context: context, inlineIntent: run.inlinePresentationIntent, link: run.link)
+ }
+ let presentation = builder.finish()
+ if presentation.text.isEmpty, source.isEmpty == false {
+ return .init(text: source)
+ }
+ return presentation
+ }
+
+ private static func containsMarkdownSyntax(in source: String) -> Bool {
+ if source.contains("```") || source.contains("`") || source.contains("**") || source.contains("__")
+ || source.contains("~~") || source.contains("](")
+ {
+ return true
+ }
+
+ let nsSource = source as NSString
+ let fullRange = NSRange(location: 0, length: nsSource.length)
+ let blockPatterns = [
+ #"(?m)^\s{0,3}#{1,6}\s+"#,
+ #"(?m)^\s{0,3}>\s?"#,
+ #"(?m)^\s{0,3}(?:[-*+]|\d+[.)])\s+"#,
+ #"(?m)^\s{0,3}(?:---+|\*\*\*+|___+)\s*$"#,
+ #"(?m)^\s*\|.+\|\s*$"#,
+ #"(^|[\s([{])\*[^*\n]+\*($|[\s.,;:!?)\]}])"#,
+ #"(^|[\s([{])_[^_\n]+_($|[\s.,;:!?)\]}])"#,
+ ]
+ for pattern in blockPatterns {
+ guard let expression = try? NSRegularExpression(pattern: pattern) else {
+ continue
+ }
+ if expression.firstMatch(in: source, options: [], range: fullRange) != nil {
+ return true
+ }
+ }
+ return false
+ }
+
+ private static func renderPlan(_ source: String) -> Presentation {
+ var result = Presentation(text: "")
+ var offset = 0
+ let lines = source.components(separatedBy: "\n")
+ for index in lines.indices {
+ if index > lines.startIndex {
+ result.text += "\n"
+ offset += 1
+ }
+
+ let line = lines[index]
+ let renderedLine: String
+ let status: ReviewMonitorLog.PlanStatus?
+ if let parsed = planStatusAndContent(in: line) {
+ status = parsed.status
+ renderedLine = planMarker(for: parsed.status) + parsed.content
+ } else {
+ status = nil
+ renderedLine = line
+ }
+
+ let length = utf16Length(renderedLine)
+ if length > 0, status != nil {
+ result.styleRuns.append(
+ .init(
+ range: NSRange(location: offset, length: length),
+ style: .plan(status: status)
+ ))
+ }
+ result.text += renderedLine
+ offset += length
+ }
+ return result
+ }
+
+ private static func offset(_ range: NSRange, by location: Int, limitingTo length: Int) -> NSRange {
+ let local = NSIntersectionRange(range, NSRange(location: 0, length: length))
+ guard local.length > 0 else {
+ return NSRange(location: location, length: 0)
+ }
+ return NSRange(location: location + local.location, length: local.length)
+ }
+
+ private static func planStatus(in line: String) -> ReviewMonitorLog.PlanStatus? {
+ planStatusAndContent(in: line)?.status
+ }
+
+ private static func planStatusAndContent(in line: String) -> (status: ReviewMonitorLog.PlanStatus, content: String)?
+ {
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+ guard trimmed.hasPrefix("["),
+ let closingIndex = trimmed.firstIndex(of: "]")
+ else {
+ return nil
+ }
+ let rawStatus = trimmed[trimmed.index(after: trimmed.startIndex).. String {
+ let start = line.index(after: closingIndex)
+ return line[start...].trimmingCharacters(in: .whitespaces)
+ }
+
+ private static func planMarker(for status: ReviewMonitorLog.PlanStatus) -> String {
+ switch status {
+ case .completed:
+ return "\u{2713} "
+ case .inProgress:
+ return "\u{2022} "
+ case .pending:
+ return "\u{25A1} "
+ case .failed:
+ return "! "
+ }
+ }
+
+ private static func utf16Length(_ text: String) -> Int {
+ (text as NSString).length
+ }
+
+ private struct MarkdownContext: Equatable {
+ enum Kind: Equatable {
+ case paragraph
+ case heading(level: Int)
+ case unorderedListItem(ordinal: Int, listIdentity: Int)
+ case orderedListItem(ordinal: Int, listIdentity: Int)
+ case blockquote
+ case codeBlock(languageHint: String?)
+ case thematicBreak
+ case table
+ }
+
+ var identity: Int
+ var kind: Kind
+
+ init(presentationIntent: PresentationIntent?) {
+ let components = presentationIntent?.components ?? []
+ var codeBlock: (identity: Int, languageHint: String?)?
+ var header: (identity: Int, level: Int)?
+ var listItem: (identity: Int, ordinal: Int)?
+ var orderedListIdentity: Int?
+ var unorderedListIdentity: Int?
+ var blockquoteIdentity: Int?
+ var thematicBreakIdentity: Int?
+ var paragraphIdentity: Int?
+ var tableIdentity: Int?
+ var tableCellIdentity: Int?
+
+ for component in components {
+ switch component.kind {
+ case .codeBlock(let languageHint):
+ if codeBlock == nil {
+ codeBlock = (component.identity, languageHint)
+ }
+ case .header(let level):
+ if header == nil {
+ header = (component.identity, level)
+ }
+ case .listItem(let ordinal):
+ if listItem == nil {
+ listItem = (component.identity, ordinal)
+ }
+ case .orderedList:
+ if orderedListIdentity == nil {
+ orderedListIdentity = component.identity
+ }
+ case .unorderedList:
+ if unorderedListIdentity == nil {
+ unorderedListIdentity = component.identity
+ }
+ case .blockQuote:
+ if blockquoteIdentity == nil {
+ blockquoteIdentity = component.identity
+ }
+ case .thematicBreak:
+ if thematicBreakIdentity == nil {
+ thematicBreakIdentity = component.identity
+ }
+ case .paragraph:
+ if paragraphIdentity == nil {
+ paragraphIdentity = component.identity
+ }
+ case .table:
+ if tableIdentity == nil {
+ tableIdentity = component.identity
+ }
+ case .tableCell:
+ if tableCellIdentity == nil {
+ tableCellIdentity = component.identity
+ }
+ case .tableHeaderRow, .tableRow:
+ break
+ @unknown default:
+ break
+ }
+ }
+
+ if let codeBlock {
+ self.identity = codeBlock.identity
+ self.kind = .codeBlock(languageHint: codeBlock.languageHint)
+ return
+ }
+ if let header {
+ self.identity = header.identity
+ self.kind = .heading(level: header.level)
+ return
+ }
+ if let listItem {
+ self.identity = listItem.identity
+ if let orderedListIdentity {
+ self.kind = .orderedListItem(ordinal: listItem.ordinal, listIdentity: orderedListIdentity)
+ } else if let unorderedListIdentity {
+ self.kind = .unorderedListItem(ordinal: listItem.ordinal, listIdentity: unorderedListIdentity)
+ } else {
+ self.kind = .unorderedListItem(ordinal: listItem.ordinal, listIdentity: listItem.identity)
+ }
+ return
+ }
+ if let blockquoteIdentity {
+ self.identity = paragraphIdentity ?? blockquoteIdentity
+ self.kind = .blockquote
+ return
+ }
+ if let thematicBreakIdentity {
+ self.identity = thematicBreakIdentity
+ self.kind = .thematicBreak
+ return
+ }
+ if let tableIdentity {
+ self.identity = tableCellIdentity ?? tableIdentity
+ self.kind = .table
+ return
+ }
+
+ self.identity = paragraphIdentity ?? 0
+ self.kind = .paragraph
+ }
+
+ var isCodeBlock: Bool {
+ if case .codeBlock = kind {
+ return true
+ }
+ return false
+ }
+
+ var blockStyle: ReviewMonitorLog.TextStyle? {
+ switch kind {
+ case .paragraph:
+ return nil
+ case .heading(let level):
+ return .heading(level: level)
+ case .unorderedListItem, .orderedListItem:
+ return .bullet
+ case .blockquote:
+ return .blockquote
+ case .codeBlock:
+ return .codeFence
+ case .thematicBreak:
+ return .muted
+ case .table:
+ return .body
+ }
+ }
+
+ func separator(after previous: MarkdownContext?) -> Int {
+ guard let previous, previous != self else {
+ return 0
+ }
+ switch (previous.kind, kind) {
+ case (.unorderedListItem(_, let previousList), .unorderedListItem(_, let currentList))
+ where previousList == currentList:
+ return 1
+ case (.orderedListItem(_, let previousList), .orderedListItem(_, let currentList))
+ where previousList == currentList:
+ return 1
+ default:
+ return 2
+ }
+ }
+
+ var prefix: String {
+ switch kind {
+ case .unorderedListItem:
+ return "- "
+ case .orderedListItem(let ordinal, _):
+ return "\(ordinal). "
+ case .paragraph, .heading, .blockquote, .codeBlock, .thematicBreak, .table:
+ return ""
+ }
+ }
+ }
+
+ private struct MarkdownPresentationBuilder {
+ var blockID: ReviewMonitorLog.BlockID
+ var text = ""
+ var utf16Offset = 0
+ var styleRuns: [ReviewMonitorLog.TextRun] = []
+ var decorations: [ReviewMonitorLog.Decoration] = []
+ var currentContext: MarkdownContext?
+ var activeCodeBlockStart: Int?
+
+ mutating func append(
+ _ segment: String,
+ context: MarkdownContext,
+ inlineIntent: InlinePresentationIntent?,
+ link: URL?
+ ) {
+ if currentContext != context {
+ closeCodeBlockIfNeeded()
+ appendNewlines(context.separator(after: currentContext))
+ appendPrefix(for: context)
+ if context.isCodeBlock {
+ activeCodeBlockStart = utf16Offset
+ }
+ currentContext = context
+ }
+
+ let range = appendRaw(segment)
+ guard range.length > 0 else {
+ return
+ }
+
+ if let style = context.blockStyle {
+ styleRuns.append(.init(range: range, style: style))
+ }
+ appendInlineStyles(in: range, inlineIntent: inlineIntent, link: link)
+ }
+
+ mutating func finish() -> Presentation {
+ closeCodeBlockIfNeeded()
+ return .init(text: text, styleRuns: styleRuns, decorations: decorations)
+ }
+
+ private mutating func appendPrefix(for context: MarkdownContext) {
+ let prefix = context.prefix
+ guard prefix.isEmpty == false else {
+ return
+ }
+ let range = appendRaw(prefix)
+ if range.length > 0, let style = context.blockStyle {
+ styleRuns.append(.init(range: range, style: style))
+ }
+ }
+
+ private mutating func appendNewlines(_ count: Int) {
+ guard count > 0 else {
+ return
+ }
+ let existing = trailingNewlineCount()
+ let needed = max(0, count - existing)
+ guard needed > 0 else {
+ return
+ }
+ _ = appendRaw(String(repeating: "\n", count: needed))
+ }
+
+ private mutating func appendRaw(_ string: String) -> NSRange {
+ let length = ReviewMonitorLogStyler.utf16Length(string)
+ let range = NSRange(location: utf16Offset, length: length)
+ text += string
+ utf16Offset += length
+ return range
+ }
+
+ private mutating func appendInlineStyles(
+ in range: NSRange,
+ inlineIntent: InlinePresentationIntent?,
+ link: URL?
+ ) {
+ if let inlineIntent {
+ if inlineIntent.contains(.stronglyEmphasized) {
+ styleRuns.append(.init(range: range, style: .strong))
+ }
+ if inlineIntent.contains(.emphasized) {
+ styleRuns.append(.init(range: range, style: .emphasis))
+ }
+ if inlineIntent.contains(.code) {
+ styleRuns.append(.init(range: range, style: .inlineCode))
+ }
+ if inlineIntent.contains(.strikethrough) {
+ styleRuns.append(.init(range: range, style: .strikethrough))
+ }
+ }
+ if link != nil {
+ styleRuns.append(.init(range: range, style: .link))
+ }
+ }
+
+ private mutating func closeCodeBlockIfNeeded() {
+ guard let start = activeCodeBlockStart else {
+ return
+ }
+ let range = NSRange(location: start, length: max(0, utf16Offset - start))
+ if range.length > 0 {
+ decorations.append(.init(blockID: blockID, range: range, style: .codeBlock))
+ }
+ activeCodeBlockStart = nil
+ }
+
+ private func trailingNewlineCount() -> Int {
+ var count = 0
+ for character in text.reversed() {
+ guard character == "\n" else {
+ return count
+ }
+ count += 1
+ }
+ return count
+ }
+ }
+
+}
diff --git a/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogRenderer.swift b/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogRenderer.swift
new file mode 100644
index 00000000..b580ca36
--- /dev/null
+++ b/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogRenderer.swift
@@ -0,0 +1,30 @@
+struct ReviewMonitorRenderedLogDocument: Equatable, Sendable {
+ var source: ReviewMonitorLog.Document
+ var display: ReviewMonitorLog.Document
+}
+
+actor ReviewMonitorLogRenderer {
+ private var displayDocument = ReviewMonitorLog.Document()
+
+ func reset() {
+ displayDocument = ReviewMonitorLog.Document()
+ }
+
+ func render(sourceDocument: ReviewMonitorLog.Document) -> ReviewMonitorRenderedLogDocument {
+ renderedDocument(from: sourceDocument)
+ }
+
+ private func renderedDocument(
+ from source: ReviewMonitorLog.Document
+ ) -> ReviewMonitorRenderedLogDocument {
+ let display = ReviewMonitorCommandOutputDisplayDocument.make(
+ from: source,
+ previousDisplay: displayDocument
+ )
+ displayDocument = display
+ return .init(
+ source: source,
+ display: display
+ )
+ }
+}
diff --git a/Sources/ReviewUI/Detail/ReviewMonitorLogRenderingViews.swift b/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogRenderingViews.swift
similarity index 100%
rename from Sources/ReviewUI/Detail/ReviewMonitorLogRenderingViews.swift
rename to Sources/ReviewChatLogUI/Detail/ReviewMonitorLogRenderingViews.swift
diff --git a/Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift b/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogScrollView.swift
similarity index 98%
rename from Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift
rename to Sources/ReviewChatLogUI/Detail/ReviewMonitorLogScrollView.swift
index 31ccbf1c..9d4014a0 100644
--- a/Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift
+++ b/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogScrollView.swift
@@ -1,6 +1,5 @@
import AppKit
import ObjectiveC.runtime
-import CodexReview
@MainActor
final class ReviewMonitorLogScrollView: NSScrollView {
@@ -41,7 +40,6 @@ final class ReviewMonitorLogScrollView: NSScrollView {
private var displayedFinderSupplementSignature: Int?
private var sourceDocument: ReviewMonitorLog.Document?
private var currentDisplayDocument: ReviewMonitorLog.Document?
- private var logProjection = ReviewMonitorLog.Projection()
private var liveResizeRestorationTarget: ScrollRestorationTarget?
private var isFindQueryActive = false
private var activeFindQueryString: String?
@@ -132,6 +130,7 @@ final class ReviewMonitorLogScrollView: NSScrollView {
func resetFindStateForContentReuse() {
endFindSession()
logDocumentView.resetCommandOutputPanelState()
+ displayedPresentationSignature = nil
displayedFinderSupplementSignature = nil
sourceDocument = nil
}
@@ -172,7 +171,6 @@ final class ReviewMonitorLogScrollView: NSScrollView {
@discardableResult
func clear() -> Bool {
- logProjection = ReviewMonitorLog.Projection()
displayedRevision = nil
displayedPresentationSignature = nil
displayedFinderSupplementSignature = nil
@@ -187,20 +185,6 @@ final class ReviewMonitorLogScrollView: NSScrollView {
)
}
- @discardableResult
- func render(
- entries: [ReviewLogEntry],
- restoring restorationTarget: ScrollRestorationTarget,
- allowIncrementalUpdate: Bool
- ) -> Bool {
- let document = logProjection.render(entries: entries)
- return render(
- document: document,
- restoring: restorationTarget,
- allowIncrementalUpdate: allowIncrementalUpdate
- )
- }
-
@discardableResult
func render(
document: ReviewMonitorLog.Document,
@@ -507,6 +491,7 @@ final class ReviewMonitorLogScrollView: NSScrollView {
)
let presentationChanged = displayedPresentationSignature != presentationSignature
let finderSupplementChanged = displayedFinderSupplementSignature != finderSupplementSignature
+ let shouldRestoreScroll = currentScrollRestorationTarget != restorationTarget
let shouldRefreshFindSession = shouldRefreshFindSessionForFinderSupplementChange(
to: finderSupplementSignature
)
@@ -514,6 +499,8 @@ final class ReviewMonitorLogScrollView: NSScrollView {
logDocumentView.applyPresentation(document)
invalidateDocumentLayout()
restoreScrollPosition(restorationTarget, countAsAutoFollow: countBottomRestoreAsAutoFollow)
+ } else if shouldRestoreScroll {
+ restoreScrollPosition(restorationTarget, countAsAutoFollow: countBottomRestoreAsAutoFollow)
} else if finderSupplementChanged {
logDocumentView.updateFinderSupplement(document)
}
@@ -1065,7 +1052,7 @@ final class ReviewMonitorLogScrollView: NSScrollView {
let maxOffset = maximumVerticalScrollOffset()
let minOffset = minimumVerticalScrollOffset()
guard maxOffset > minOffset else {
- return .top
+ return .bottom
}
let offset = contentView.bounds.origin.y
@@ -1270,7 +1257,7 @@ extension ReviewMonitorLogScrollView {
false
}
- var usesLegacyLayoutManagerForTesting: Bool {
+ var usesLogLayoutManagerForTesting: Bool {
false
}
diff --git a/Sources/ReviewUI/Detail/ReviewMonitorLogTextFinder.swift b/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogTextFinder.swift
similarity index 100%
rename from Sources/ReviewUI/Detail/ReviewMonitorLogTextFinder.swift
rename to Sources/ReviewChatLogUI/Detail/ReviewMonitorLogTextFinder.swift
diff --git a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift
new file mode 100644
index 00000000..22cdf98d
--- /dev/null
+++ b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift
@@ -0,0 +1,651 @@
+import CodexKit
+import Foundation
+
+@MainActor
+struct ReviewMonitorCodexChatLogProjection {
+ private var documentProjection = ReviewMonitorLogDocumentProjection()
+
+ var currentDocument: ReviewMonitorLog.Document {
+ documentProjection.currentDocument
+ }
+
+ mutating func reset() {
+ documentProjection.reset()
+ }
+
+ mutating func render(
+ from chat: CodexChat,
+ chatCreatedAt: Date?,
+ chatUpdatedAt: Date?
+ ) -> ReviewMonitorLog.Document? {
+ var turnStatusByID: [CodexTurnID: CodexTurnStatus] = [:]
+ for turn in chat.turns {
+ if let status = turn.status {
+ turnStatusByID[turn.id] = status
+ }
+ }
+ return render(
+ items: chat.items.map { item in
+ CodexChatModelLogItem(
+ item: item,
+ turnStatus: item.turnID.flatMap { turnStatusByID[$0] }
+ )
+ },
+ turnStatus: nil,
+ chatCreatedAt: chatCreatedAt,
+ chatUpdatedAt: chatUpdatedAt
+ )
+ }
+
+ mutating func render(
+ from snapshot: CodexTurnSnapshot,
+ chatCreatedAt: Date?,
+ chatUpdatedAt: Date?
+ ) -> ReviewMonitorLog.Document? {
+ render(
+ items: snapshot.items.map {
+ CodexThreadSnapshotLogItem(
+ item: $0,
+ turnID: snapshot.id,
+ turnStatus: snapshot.status
+ )
+ },
+ turnStatus: snapshot.status,
+ chatCreatedAt: chatCreatedAt,
+ chatUpdatedAt: chatUpdatedAt
+ )
+ }
+
+ mutating func render(
+ from snapshot: CodexThreadSnapshot,
+ chatCreatedAt: Date?,
+ chatUpdatedAt: Date?
+ ) -> ReviewMonitorLog.Document? {
+ let items = (snapshot.turns ?? []).flatMap { turn in
+ turn.items.map {
+ CodexThreadSnapshotLogItem(
+ item: $0,
+ turnID: turn.id,
+ turnStatus: turn.status
+ )
+ }
+ }
+ return render(
+ items: items,
+ turnStatus: nil,
+ chatCreatedAt: chatCreatedAt ?? snapshot.createdAt,
+ chatUpdatedAt: chatUpdatedAt ?? snapshot.updatedAt
+ )
+ }
+
+ private mutating func render(
+ items: [Item],
+ turnStatus: CodexTurnStatus?,
+ chatCreatedAt: Date?,
+ chatUpdatedAt: Date?
+ ) -> ReviewMonitorLog.Document? {
+ guard items.isEmpty == false else {
+ documentProjection.reset()
+ return nil
+ }
+ let suppressUserMessages = items.contains { item in
+ item.kind == .enteredReviewMode || item.kind == .exitedReviewMode
+ }
+ let blocks = items.flatMap {
+ projectedBlocks(
+ from: $0,
+ turnStatus: turnStatus,
+ chatCreatedAt: chatCreatedAt,
+ chatUpdatedAt: chatUpdatedAt,
+ suppressUserMessages: suppressUserMessages
+ )
+ }
+ guard blocks.isEmpty == false else {
+ documentProjection.reset()
+ return nil
+ }
+ return documentProjection.render(projectedBlocks: blocks)
+ }
+
+ private func projectedBlocks(
+ from item: Item,
+ turnStatus: CodexTurnStatus?,
+ chatCreatedAt: Date?,
+ chatUpdatedAt: Date?,
+ suppressUserMessages: Bool
+ ) -> [ReviewMonitorLogProjectedBlock] {
+ switch item.content {
+ case .message(let message):
+ guard suppressUserMessages == false || message.role != .user else {
+ return []
+ }
+ return [
+ projectedBlock(
+ item,
+ kind: .agentMessage,
+ text: message.text
+ )
+ ]
+ case .plan(let markdown):
+ return [
+ projectedBlock(
+ item,
+ kind: logKind(for: item, fallback: .plan),
+ text: markdown
+ )
+ ]
+ case .reasoning(let reasoning):
+ return [
+ projectedBlock(
+ item,
+ kind: logKind(for: item, fallback: .reasoning),
+ text: reasoning.text
+ )
+ ]
+ case .command(let command):
+ return commandBlocks(
+ item: item,
+ command: command,
+ turnStatus: turnStatus
+ )
+ case .fileChange(let fileChange):
+ let title = fileChange.path ?? "File change"
+ return [
+ projectedBlock(
+ item,
+ kind: .commandOutput,
+ groupID: sourceBlockID(for: item),
+ text: fileChange.output?.nilIfEmpty ?? title,
+ metadata: .init(
+ sourceType: "fileChange",
+ title: title,
+ status: fileChangeStatus(
+ item: item,
+ fileChange: fileChange,
+ turnStatus: turnStatus
+ ),
+ path: fileChange.path
+ )
+ )
+ ]
+ case .toolCall(let toolCall):
+ if item.kind == .webSearch {
+ return webSearchBlocks(item: item, toolCall: toolCall)
+ }
+ return toolCallBlocks(item: item, toolCall: toolCall)
+ case .contextCompaction(let text):
+ let title = text ?? "Context compaction"
+ return [
+ projectedBlock(
+ item,
+ kind: .contextCompaction,
+ text: title,
+ metadata: .init(
+ sourceType: "contextCompaction",
+ title: title,
+ status: turnStatus?.rawValue
+ )
+ )
+ ]
+ case .diagnostic(let message):
+ return [
+ projectedBlock(
+ item,
+ kind: logKind(for: item, fallback: .diagnostic),
+ text: message,
+ metadata: metadata(
+ for: item,
+ sourceType: item.kind.rawValue,
+ title: message,
+ status: itemStatus(for: item, turnStatus: turnStatus).rawValue
+ )
+ )
+ ]
+ case .log(let message):
+ return [
+ projectedBlock(
+ item,
+ kind: .diagnostic,
+ text: message,
+ metadata: metadata(
+ for: item,
+ sourceType: "log",
+ title: message,
+ status: itemStatus(for: item, turnStatus: turnStatus).rawValue
+ )
+ )
+ ]
+ case .unknown(let raw):
+ let title = raw.text ?? raw.rawType
+ return [
+ projectedBlock(
+ item,
+ kind: logKind(for: item, fallback: .event),
+ text: unknownText(title: title, detail: raw.text),
+ metadata: metadata(
+ for: item,
+ sourceType: item.kind.rawValue,
+ title: title,
+ status: item.itemStatus?.rawValue,
+ detail: raw.text
+ )
+ )
+ ]
+ }
+ }
+
+ private func unknownText(title: String, detail: String?) -> String {
+ guard let detail, detail != title else {
+ return title
+ }
+ return "\(title)\n\(detail)"
+ }
+
+ private func commandBlocks(
+ item: Item,
+ command: CodexCommand,
+ turnStatus: CodexTurnStatus?
+ ) -> [ReviewMonitorLogProjectedBlock] {
+ let groupID = sourceBlockID(for: item)
+ let metadata = commandMetadata(
+ item: item,
+ command: command,
+ turnStatus: turnStatus
+ )
+ let commandLine = command.command.nilIfEmpty
+ let output = command.output ?? ""
+ if (commandLine == nil || command.command == "Command"), output.isEmpty == false {
+ return [
+ .init(
+ id: derivedBlockID(prefix: "commandOutput", item: item),
+ kind: .commandOutput,
+ groupID: groupID,
+ text: output,
+ metadata: outputOnlyCommandMetadata(
+ item: item,
+ command: command,
+ turnStatus: turnStatus
+ )
+ )
+ ]
+ }
+ guard let commandLine else {
+ return []
+ }
+ var blocks = [
+ ReviewMonitorLogProjectedBlock(
+ id: derivedBlockID(prefix: "command", item: item),
+ kind: .command,
+ groupID: groupID,
+ text: "$ \(commandLine)",
+ metadata: metadata
+ )
+ ]
+ if output.isEmpty == false {
+ blocks.append(
+ ReviewMonitorLogProjectedBlock(
+ id: derivedBlockID(prefix: "commandOutput", item: item),
+ kind: .commandOutput,
+ groupID: groupID,
+ text: output,
+ metadata: metadata
+ ))
+ }
+ return blocks
+ }
+
+ private func webSearchBlocks(
+ item: Item,
+ toolCall: CodexToolCall
+ ) -> [ReviewMonitorLogProjectedBlock] {
+ let query = toolCall.arguments ?? toolCall.name ?? "Web search"
+ return [
+ projectedBlock(
+ item,
+ kind: .toolCall,
+ text: toolCall.result ?? toolCall.error ?? "Web search: \(query)",
+ metadata: metadata(
+ for: item,
+ sourceType: "webSearch",
+ title: "Web search",
+ status: toolCall.status?.rawValue,
+ query: query,
+ resultText: toolCall.result,
+ errorText: toolCall.error
+ )
+ )
+ ]
+ }
+
+ private func toolCallBlocks(
+ item: Item,
+ toolCall: CodexToolCall
+ ) -> [ReviewMonitorLogProjectedBlock] {
+ let label = [toolCall.namespace, toolCall.server, toolCall.name]
+ .compactMap { $0?.nilIfEmpty }
+ .joined(separator: ".")
+ return [
+ projectedBlock(
+ item,
+ kind: .toolCall,
+ text: toolCall.error?.nilIfEmpty
+ ?? toolCall.result?.nilIfEmpty
+ ?? label.nilIfEmpty
+ ?? "Tool call",
+ metadata: metadata(
+ for: item,
+ sourceType: item.kind.rawValue,
+ title: label.nilIfEmpty,
+ status: toolCall.status?.rawValue,
+ detail: toolCall.arguments,
+ namespace: toolCall.namespace,
+ server: toolCall.server,
+ tool: toolCall.name,
+ resultText: toolCall.result,
+ errorText: toolCall.error
+ )
+ )
+ ]
+ }
+
+ private func projectedBlock(
+ _ item: Item,
+ kind: ReviewMonitorLog.Kind,
+ groupID: String? = nil,
+ text: String,
+ metadata: ReviewMonitorLog.Metadata? = nil
+ ) -> ReviewMonitorLogProjectedBlock {
+ .init(
+ id: derivedBlockID(prefix: kind.rawValue, item: item),
+ kind: kind,
+ groupID: groupID,
+ text: text,
+ metadata: metadata
+ )
+ }
+
+ private func derivedBlockID(
+ prefix: String,
+ item: Item
+ ) -> ReviewMonitorLog.BlockID {
+ ReviewMonitorLog.BlockID("\(prefix):\(sourceBlockID(for: item))")
+ }
+
+ private func sourceBlockID(for item: Item) -> String {
+ item.sourceID
+ }
+
+ private func logKind(
+ for item: Item,
+ fallback: ReviewMonitorLog.Kind
+ ) -> ReviewMonitorLog.Kind {
+ ReviewMonitorLog.Kind(rawValue: item.kind.rawValue) ?? fallback
+ }
+
+ private func metadata(
+ for item: Item,
+ sourceType: String,
+ title: String? = nil,
+ status: String? = nil,
+ detail: String? = nil,
+ namespace: String? = nil,
+ server: String? = nil,
+ tool: String? = nil,
+ query: String? = nil,
+ resultText: String? = nil,
+ errorText: String? = nil
+ ) -> ReviewMonitorLog.Metadata {
+ .init(
+ sourceType: sourceType,
+ title: title,
+ status: status,
+ detail: detail,
+ itemID: sourceBlockID(for: item),
+ namespace: namespace,
+ server: server,
+ tool: tool,
+ query: query,
+ resultText: resultText,
+ errorText: errorText
+ )
+ }
+
+ private func commandMetadata(
+ item: Item,
+ command: CodexCommand,
+ turnStatus: CodexTurnStatus?
+ ) -> ReviewMonitorLog.Metadata {
+ let status = commandStatus(item: item, command: command, turnStatus: turnStatus)
+ return .init(
+ sourceType: "commandExecution",
+ status: status,
+ itemID: sourceBlockID(for: item),
+ command: command.command,
+ cwd: command.cwd,
+ exitCode: command.exitCode,
+ startedAt: command.startedAt,
+ completedAt: command.completedAt,
+ durationMs: command.durationMilliseconds,
+ commandStatus: status
+ )
+ }
+
+ private func outputOnlyCommandMetadata(
+ item: Item,
+ command: CodexCommand,
+ turnStatus: CodexTurnStatus?
+ ) -> ReviewMonitorLog.Metadata {
+ guard
+ command.cwd != nil || command.exitCode != nil || command.status != nil
+ || command.startedAt != nil || command.completedAt != nil
+ || command.durationMilliseconds != nil
+ else {
+ let status = itemStatus(for: item, turnStatus: turnStatus)
+ return .init(
+ sourceType: "command",
+ title: "Command output",
+ status: terminalStatus(status) ? status.rawValue : "completed"
+ )
+ }
+ return commandMetadata(
+ item: item,
+ command: command,
+ turnStatus: turnStatus
+ )
+ }
+
+ private func commandStatus(
+ item: Item,
+ command: CodexCommand,
+ turnStatus: CodexTurnStatus?
+ ) -> String {
+ let status = itemStatus(for: item, turnStatus: turnStatus)
+ if let commandStatus = command.status,
+ terminalStatus(commandStatus),
+ commandStatus != .completed
+ {
+ return commandStatus.rawValue
+ }
+ if let exitCode = command.exitCode {
+ return exitCode == 0 ? CodexTurnStatus.completed.rawValue : CodexTurnStatus.failed.rawValue
+ }
+ if terminalStatus(status) {
+ return status.rawValue
+ }
+ if let commandStatus = command.status,
+ terminalStatus(commandStatus)
+ {
+ return commandStatus.rawValue
+ }
+ return command.status?.rawValue ?? status.rawValue
+ }
+
+ private func fileChangeStatus(
+ item: Item,
+ fileChange: CodexFileChange,
+ turnStatus: CodexTurnStatus?
+ ) -> String {
+ let status = itemStatus(for: item, turnStatus: turnStatus)
+ if terminalStatus(status) {
+ return status.rawValue
+ }
+ return fileChange.status?.rawValue ?? status.rawValue
+ }
+
+ private func itemStatus(
+ for item: Item,
+ turnStatus: CodexTurnStatus?
+ ) -> CodexTurnStatus {
+ let itemStatus = item.itemStatus
+ let turnStatus = turnStatus ?? item.projectionTurnStatus
+ if let turnStatus,
+ terminalStatus(turnStatus),
+ itemStatus.map(terminalStatus) != true
+ {
+ return turnStatus
+ }
+ return itemStatus ?? turnStatus ?? .running
+ }
+
+ private func terminalStatus(_ status: CodexTurnStatus) -> Bool {
+ switch status {
+ case .running, .unknown:
+ return false
+ case .completed, .failed, .interrupted, .cancelled:
+ return true
+ }
+ }
+}
+
+@MainActor
+private protocol CodexChatLogProjectionItem {
+ var itemID: String { get }
+ var sourceID: String { get }
+ var projectionTurnID: CodexTurnID? { get }
+ var projectionTurnStatus: CodexTurnStatus? { get }
+ var kind: CodexThreadItem.Kind { get }
+ var content: CodexThreadItem.Content { get }
+ var itemStatus: CodexTurnStatus? { get }
+}
+
+extension CodexItem: CodexChatLogProjectionItem {
+ fileprivate var sourceID: String {
+ id.rawValue
+ }
+
+ fileprivate var projectionTurnID: CodexTurnID? {
+ turnID
+ }
+
+ fileprivate var projectionTurnStatus: CodexTurnStatus? {
+ turn?.status
+ }
+
+ var itemStatus: CodexTurnStatus? {
+ content.reviewMonitorLogItemStatus
+ }
+}
+
+@MainActor
+private struct CodexChatModelLogItem: CodexChatLogProjectionItem {
+ var item: CodexItem
+ var turnStatus: CodexTurnStatus?
+
+ var itemID: String {
+ item.itemID
+ }
+
+ var sourceID: String {
+ item.id.rawValue
+ }
+
+ var projectionTurnID: CodexTurnID? {
+ item.turnID
+ }
+
+ var projectionTurnStatus: CodexTurnStatus? {
+ turnStatus
+ }
+
+ var kind: CodexThreadItem.Kind {
+ item.kind
+ }
+
+ var content: CodexThreadItem.Content {
+ item.content
+ }
+
+ var itemStatus: CodexTurnStatus? {
+ item.content.reviewMonitorLogItemStatus
+ }
+}
+
+@MainActor
+private struct CodexThreadSnapshotLogItem: CodexChatLogProjectionItem {
+ var item: CodexThreadItem
+ var turnID: CodexTurnID
+ var turnStatus: CodexTurnStatus?
+
+ var itemID: String {
+ item.id
+ }
+
+ var sourceID: String {
+ CodexChatItemID(
+ rawItemID: semanticItemID,
+ turnID: turnID
+ ).rawValue
+ }
+
+ var projectionTurnID: CodexTurnID? {
+ turnID
+ }
+
+ var projectionTurnStatus: CodexTurnStatus? {
+ turnStatus
+ }
+
+ var kind: CodexThreadItem.Kind {
+ item.kind
+ }
+
+ var content: CodexThreadItem.Content {
+ item.content
+ }
+
+ var itemStatus: CodexTurnStatus? {
+ item.content.reviewMonitorLogItemStatus
+ }
+
+ private var semanticItemID: String {
+ switch item.kind {
+ case .enteredReviewMode:
+ "review-marker:enteredReviewMode"
+ case .exitedReviewMode:
+ "review-marker:exitedReviewMode"
+ default:
+ item.id
+ }
+ }
+}
+
+private extension CodexThreadItem.Content {
+ var reviewMonitorLogItemStatus: CodexTurnStatus? {
+ switch self {
+ case .command(let command):
+ return command.status
+ case .fileChange(let fileChange):
+ return fileChange.status
+ case .toolCall(let toolCall):
+ return toolCall.status
+ case .message, .plan, .reasoning, .contextCompaction, .diagnostic, .log, .unknown:
+ return nil
+ }
+ }
+}
+
+private extension String {
+ var nilIfEmpty: String? {
+ isEmpty ? nil : self
+ }
+}
diff --git a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift
new file mode 100644
index 00000000..0f25164d
--- /dev/null
+++ b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift
@@ -0,0 +1,111 @@
+import CodexKit
+import Foundation
+
+@MainActor
+enum ReviewMonitorLogSourceChange: Equatable {
+ case replaceAll(ReviewMonitorLog.Document)
+ case update(ReviewMonitorLog.Document)
+ case clear
+
+ var sourceDocument: ReviewMonitorLog.Document? {
+ switch self {
+ case .replaceAll(let document),
+ .update(let document):
+ return document
+ case .clear:
+ return nil
+ }
+ }
+
+ var allowsIncrementalRender: Bool {
+ switch self {
+ case .update:
+ return true
+ case .replaceAll,
+ .clear:
+ return false
+ }
+ }
+}
+
+@MainActor
+struct ReviewMonitorCodexChatLogSourceProjection {
+ private var logProjection = ReviewMonitorCodexChatLogProjection()
+ private var hasLogDocument = false
+
+ mutating func reset() {
+ logProjection.reset()
+ hasLogDocument = false
+ }
+
+ mutating func applyBaseline(
+ from chat: CodexChat,
+ chatCreatedAt: Date?,
+ chatUpdatedAt: Date?
+ ) -> ReviewMonitorLogSourceChange? {
+ renderChat(
+ from: chat,
+ chatCreatedAt: chatCreatedAt,
+ chatUpdatedAt: chatUpdatedAt,
+ allowIncrementalUpdate: false
+ ) ?? .clear
+ }
+
+ mutating func apply(
+ _ update: CodexChatUpdate,
+ in chat: CodexChat,
+ chatCreatedAt: Date?,
+ chatUpdatedAt: Date?
+ ) -> ReviewMonitorLogSourceChange? {
+ let allowsIncrementalUpdate: Bool
+ switch update {
+ case .resynchronized:
+ allowsIncrementalUpdate = hasLogDocument
+ case .turnInserted,
+ .turnUpdated,
+ .statusChanged,
+ .phaseChanged,
+ .itemInserted,
+ .itemUpdated,
+ .itemRemoved,
+ .itemTextAppended:
+ allowsIncrementalUpdate = hasLogDocument
+ }
+ return renderChat(
+ from: chat,
+ chatCreatedAt: chatCreatedAt,
+ chatUpdatedAt: chatUpdatedAt,
+ allowIncrementalUpdate: allowsIncrementalUpdate
+ )
+ }
+
+ private mutating func renderChat(
+ from chat: CodexChat,
+ chatCreatedAt: Date?,
+ chatUpdatedAt: Date?,
+ allowIncrementalUpdate: Bool
+ ) -> ReviewMonitorLogSourceChange? {
+ guard
+ let document = logProjection.render(
+ from: chat,
+ chatCreatedAt: chatCreatedAt,
+ chatUpdatedAt: chatUpdatedAt
+ )
+ else {
+ return clearIfNeeded()
+ }
+ defer {
+ hasLogDocument = true
+ }
+ return allowIncrementalUpdate ? .update(document) : .replaceAll(document)
+ }
+
+ private mutating func clearIfNeeded() -> ReviewMonitorLogSourceChange? {
+ guard hasLogDocument else {
+ return nil
+ }
+ logProjection.reset()
+ hasLogDocument = false
+ return .clear
+ }
+}
diff --git a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift
new file mode 100644
index 00000000..e8a33730
--- /dev/null
+++ b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift
@@ -0,0 +1,857 @@
+import AppKit
+import CodexKit
+import OSLog
+
+private let logger = Logger(subsystem: "CodexReviewKit", category: "chat-log-target")
+
+@MainActor
+package final class ReviewMonitorCodexChatLogTarget {
+ private static let incrementalLogUpdateCoalescingDelay: Duration = .milliseconds(10)
+
+ private enum LogRenderTarget: Equatable {
+ case chat(CodexThreadID)
+
+ var debugDescription: String {
+ switch self {
+ case .chat(let id):
+ "chat(\(id.rawValue))"
+ }
+ }
+ }
+
+ package var view: NSView {
+ logScrollView
+ }
+
+ private let logScrollView = ReviewMonitorLogScrollView()
+ private var logRenderer = ReviewMonitorLogRenderer()
+ private var selectedChatObservation: CodexChatObservation?
+ private var selectedChatLogTask: Task?
+ private var boundModelContext: CodexModelContext?
+ private var boundChatID: CodexThreadID?
+ private var boundChat: CodexChat?
+ private var logScrollTargetsByChatID: [CodexThreadID: ReviewMonitorLogScrollView.ScrollRestorationTarget] = [:]
+ private var logRenderTask: Task?
+ private var logRenderGeneration: UInt64 = 0
+ private var appliedLogRenderGeneration: UInt64 = 0
+ private var hasAppliedBoundLog = false
+ private var logProjection = ReviewMonitorCodexChatLogSourceProjection()
+ private var pendingLogSourceChange: PendingLogSourceChange?
+ private var pendingLogSourceChangeTask: Task?
+
+ private struct PendingLogSourceChange {
+ var change: ReviewMonitorLogSourceChange
+ var target: LogRenderTarget
+ var initialRestorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget
+ }
+
+ package init() {}
+
+ isolated deinit {
+ selectedChatObservation?.cancel()
+ selectedChatLogTask?.cancel()
+ pendingLogSourceChangeTask?.cancel()
+ logRenderTask?.cancel()
+ }
+
+ package func bind(
+ chat: CodexChat,
+ modelContext: CodexModelContext
+ ) {
+ let selectedChatID = chat.id
+ guard boundChatID != selectedChatID || boundModelContext !== modelContext else {
+ return
+ }
+ let isSwitchingRenderedChat = boundChatID != nil && boundChatID != selectedChatID
+ cacheBoundLogScrollTarget()
+ if isSwitchingRenderedChat {
+ logScrollView.resetFindStateForContentReuse()
+ }
+ logger.debug(
+ "Binding Codex chat log chatID=\(selectedChatID.rawValue, privacy: .public) switchingChat=\(isSwitchingRenderedChat, privacy: .public)"
+ )
+ cancelSelectedChatObservation()
+ cancelPendingLogSourceChange()
+ resetLogRenderer()
+ logProjection.reset()
+ boundChatID = selectedChatID
+ boundModelContext = modelContext
+ boundChat = chat
+ startSelectedCodexChatObservation(
+ chat,
+ modelContext: modelContext,
+ target: .chat(selectedChatID),
+ initialRestorationTarget: restorationTarget(chatID: selectedChatID)
+ )
+ }
+
+ @discardableResult
+ package func clear() -> Bool {
+ cacheBoundLogScrollTarget()
+ // Keep the cancelled task referenced so the next bind can await its
+ // teardown before re-observing.
+ selectedChatLogTask?.cancel()
+ cancelSelectedChatObservation()
+ cancelPendingLogSourceChange()
+ boundChatID = nil
+ boundModelContext = nil
+ boundChat = nil
+ logProjection.reset()
+ resetLogRenderer()
+ logScrollView.resetFindStateForContentReuse()
+ return logScrollView.clear()
+ }
+
+ @discardableResult
+ package func performDisplayedTextFinderAction(_ sender: Any?) -> Bool {
+ logScrollView.performDisplayedTextFinderAction(sender)
+ }
+
+ package func validateDisplayedTextFinderAction(_ item: NSValidatedUserInterfaceItem) -> Bool {
+ logScrollView.validateDisplayedTextFinderAction(item)
+ }
+
+ @discardableResult
+ private func renderBoundLog(
+ sourceDocument: ReviewMonitorLog.Document,
+ target: LogRenderTarget,
+ restorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget,
+ allowIncrementalUpdate: Bool
+ ) -> Bool {
+ if hasAppliedBoundLog && allowIncrementalUpdate == false {
+ logger.debug(
+ "Replacing displayed Codex chat log target=\(target.debugDescription, privacy: .public) blocks=\(sourceDocument.blocks.count, privacy: .public)"
+ )
+ }
+ logRenderGeneration &+= 1
+ let generation = logRenderGeneration
+ let renderer = logRenderer
+ logRenderTask?.cancel()
+ logRenderTask = Task { @MainActor [weak self] in
+ let renderedDocument = await renderer.render(sourceDocument: sourceDocument)
+ guard Task.isCancelled == false,
+ let self,
+ self.logRenderGeneration == generation,
+ self.isCurrentLogRenderTarget(target)
+ else {
+ return
+ }
+ _ = self.logScrollView.render(
+ sourceDocument: renderedDocument.source,
+ displayDocument: renderedDocument.display,
+ restoring: restorationTarget,
+ allowIncrementalUpdate: allowIncrementalUpdate && self.hasAppliedBoundLog
+ )
+ self.appliedLogRenderGeneration = generation
+ self.hasAppliedBoundLog = true
+ }
+ return true
+ }
+
+ private func startSelectedCodexChatObservation(
+ _ chat: CodexChat,
+ modelContext: CodexModelContext,
+ target: LogRenderTarget,
+ initialRestorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget
+ ) {
+ let previousTask = selectedChatLogTask
+ previousTask?.cancel()
+ selectedChatLogTask = Task { @MainActor [weak self, weak chat, weak modelContext] in
+ // Wait for the previous observation task to unwind so its possibly
+ // in-flight observation registration is released before observing
+ // again; otherwise observe() can throw chatObservationAlreadyActive.
+ await previousTask?.value
+ guard Task.isCancelled == false, let chat, let modelContext else {
+ return
+ }
+ do {
+ let observation = try await modelContext.observe(chat)
+ guard Task.isCancelled == false,
+ let self,
+ self.boundChat === chat,
+ self.isCurrentLogRenderTarget(target)
+ else {
+ observation.cancel()
+ return
+ }
+ self.selectedChatObservation = observation
+ self.publishSelectedCodexChatLogChange(
+ self.logProjection.applyBaseline(
+ from: observation.chat,
+ chatCreatedAt: chat.createdAt,
+ chatUpdatedAt: chat.updatedAt
+ ),
+ target: target,
+ initialRestorationTarget: initialRestorationTarget
+ )
+ for await update in observation.updates {
+ guard Task.isCancelled == false,
+ self.boundChat === chat,
+ self.isCurrentLogRenderTarget(target)
+ else {
+ return
+ }
+ self.publishSelectedCodexChatLogChange(
+ self.logProjection.apply(
+ update,
+ in: observation.chat,
+ chatCreatedAt: chat.createdAt,
+ chatUpdatedAt: chat.updatedAt
+ ),
+ target: target,
+ initialRestorationTarget: initialRestorationTarget
+ )
+ }
+ } catch is CancellationError {
+ } catch {
+ logger.error(
+ "Codex chat log observation failed chatID=\(chat.id.rawValue, privacy: .public) error=\(String(describing: error), privacy: .public)"
+ )
+ // The previous chat's transcript is intentionally kept on
+ // screen until the next baseline renders, but a failed
+ // observation means no baseline will ever arrive; clear the
+ // pane instead of showing another chat's log indefinitely.
+ guard let self,
+ self.boundChat === chat,
+ self.isCurrentLogRenderTarget(target)
+ else {
+ return
+ }
+ _ = self.logScrollView.clear()
+ }
+ }
+ }
+
+ private func publishSelectedCodexChatLogChange(
+ _ change: ReviewMonitorLogSourceChange?,
+ target: LogRenderTarget,
+ initialRestorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget
+ ) {
+ guard let change else {
+ return
+ }
+ guard change.allowsIncrementalRender else {
+ cancelPendingLogSourceChange()
+ applySelectedCodexChatLogChange(
+ change,
+ target: target,
+ initialRestorationTarget: initialRestorationTarget
+ )
+ return
+ }
+ pendingLogSourceChange = .init(
+ change: change,
+ target: target,
+ initialRestorationTarget: initialRestorationTarget
+ )
+ guard pendingLogSourceChangeTask == nil else {
+ return
+ }
+ pendingLogSourceChangeTask = Task { @MainActor [weak self] in
+ try? await Task.sleep(for: Self.incrementalLogUpdateCoalescingDelay)
+ guard let self, Task.isCancelled == false else {
+ return
+ }
+ let pending = self.pendingLogSourceChange
+ self.pendingLogSourceChange = nil
+ self.pendingLogSourceChangeTask = nil
+ guard let pending else {
+ return
+ }
+ self.applySelectedCodexChatLogChange(
+ pending.change,
+ target: pending.target,
+ initialRestorationTarget: pending.initialRestorationTarget
+ )
+ }
+ }
+
+ private func applySelectedCodexChatLogChange(
+ _ change: ReviewMonitorLogSourceChange,
+ target: LogRenderTarget,
+ initialRestorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget
+ ) {
+ let hasAppliedInitialDocument = hasAppliedBoundLog
+ applySelectedCodexChatLogChange(
+ change,
+ target: target,
+ restorationTarget: hasAppliedInitialDocument
+ ? logScrollView.currentScrollRestorationTarget
+ : initialRestorationTarget,
+ allowIncrementalUpdate: hasAppliedInitialDocument && change.allowsIncrementalRender
+ )
+ }
+
+ private func applySelectedCodexChatLogChange(
+ _ change: ReviewMonitorLogSourceChange,
+ target: LogRenderTarget,
+ restorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget,
+ allowIncrementalUpdate: Bool
+ ) {
+ guard let document = change.sourceDocument else {
+ if hasAppliedBoundLog {
+ let chatID = boundChatID?.rawValue ?? "nil"
+ logger.debug(
+ "Clearing displayed Codex chat log chatID=\(chatID, privacy: .public)"
+ )
+ }
+ resetLogRenderer()
+ logScrollView.clear()
+ return
+ }
+ renderBoundLog(
+ sourceDocument: document,
+ target: target,
+ restorationTarget: restorationTarget,
+ allowIncrementalUpdate: allowIncrementalUpdate
+ )
+ }
+
+ private func isCurrentLogRenderTarget(_ target: LogRenderTarget) -> Bool {
+ switch target {
+ case .chat(let id):
+ boundChatID == id
+ }
+ }
+
+ private func cacheBoundLogScrollTarget() {
+ let restorationTarget = logScrollView.currentScrollRestorationTarget
+ if let boundChatID {
+ logScrollTargetsByChatID[boundChatID] = restorationTarget
+ }
+ }
+
+ private func resetLogRenderer() {
+ logRenderTask?.cancel()
+ logRenderTask = nil
+ logRenderGeneration &+= 1
+ appliedLogRenderGeneration = logRenderGeneration
+ hasAppliedBoundLog = false
+ logRenderer = ReviewMonitorLogRenderer()
+ }
+
+ private func cancelSelectedChatObservation() {
+ selectedChatObservation?.cancel()
+ selectedChatObservation = nil
+ }
+
+ private func cancelPendingLogSourceChange() {
+ pendingLogSourceChangeTask?.cancel()
+ pendingLogSourceChangeTask = nil
+ pendingLogSourceChange = nil
+ }
+
+ private func restorationTarget(
+ chatID: CodexThreadID
+ ) -> ReviewMonitorLogScrollView.ScrollRestorationTarget {
+ logScrollTargetsByChatID[chatID] ?? .bottom
+ }
+}
+
+#if DEBUG
+ @MainActor
+ package extension ReviewMonitorCodexChatLogTarget {
+ enum OverlayScrollerBridgeModeForTesting {
+ case live
+ case missingScrollerImpPair
+ case missingOverlayScrollersShown
+ case missingHideMethods
+ }
+
+ var selectedChatLogTaskForTesting: Task? {
+ selectedChatLogTask
+ }
+
+ var logRenderIsIdleForTesting: Bool {
+ appliedLogRenderGeneration == logRenderGeneration
+ }
+
+ var displayedTextForTesting: String {
+ logScrollView.displayedTextForTesting
+ }
+
+ var appendCount: Int {
+ logScrollView.appendCount
+ }
+
+ var replaceCount: Int {
+ logScrollView.replaceCount
+ }
+
+ var reloadCount: Int {
+ logScrollView.reloadCount
+ }
+
+ var autoFollowCount: Int {
+ logScrollView.autoFollowCount
+ }
+
+ var wordGlowCountForTesting: Int {
+ logScrollView.wordGlowCountForTesting
+ }
+
+ var wordFadeRenderingAttributeRangeCountForTesting: Int {
+ logScrollView.wordFadeRenderingAttributeRangeCountForTesting
+ }
+
+ var wordFadeStorageUsesOpaqueTextColorForTesting: Bool {
+ logScrollView.wordFadeStorageUsesOpaqueTextColorForTesting
+ }
+
+ var wordFadeDisplayInvalidationCountForTesting: Int {
+ logScrollView.wordFadeDisplayInvalidationCountForTesting
+ }
+
+ var commandOutputPanelCountForTesting: Int {
+ logScrollView.commandOutputPanelCountForTesting
+ }
+
+ var terminalDecorationRectCountForTesting: Int {
+ logScrollView.terminalDecorationRectCountForTesting
+ }
+
+ var expandedCommandOutputPanelCountForTesting: Int {
+ logScrollView.expandedCommandOutputPanelCountForTesting
+ }
+
+ var commandOutputPanelUsesTextKit2ForTesting: Bool {
+ logScrollView.commandOutputPanelUsesTextKit2ForTesting
+ }
+
+ var commandOutputPanelUsesInlineAttachmentForTesting: Bool {
+ logScrollView.commandOutputPanelUsesInlineAttachmentForTesting
+ }
+
+ var commandOutputPanelUsesButtonAttachmentForTesting: Bool {
+ logScrollView.commandOutputPanelUsesButtonAttachmentForTesting
+ }
+
+ var collapsedCommandOutputPanelAttachmentLineHeightForTesting: CGFloat? {
+ logScrollView.collapsedCommandOutputPanelAttachmentLineHeightForTesting
+ }
+
+ var collapsedCommandOutputPanelAttachmentPayloadIsEmptyForTesting: Bool {
+ logScrollView.collapsedCommandOutputPanelAttachmentPayloadIsEmptyForTesting
+ }
+
+ var commandOutputPanelUsesSystemMaterialBackgroundForTesting: Bool {
+ logScrollView.commandOutputPanelUsesSystemMaterialBackgroundForTesting
+ }
+
+ var commandOutputPanelVisibleLineCapacityForTesting: Int {
+ logScrollView.commandOutputPanelVisibleLineCapacityForTesting
+ }
+
+ var commandOutputPanelResultTextForTesting: String? {
+ logScrollView.commandOutputPanelResultTextForTesting
+ }
+
+ var commandOutputPanelTerminalTextForTesting: String? {
+ logScrollView.commandOutputPanelTerminalTextForTesting
+ }
+
+ func commandOutputPanelTerminalTextForTesting(blockID: ReviewMonitorLog.BlockID) -> String? {
+ logScrollView.commandOutputPanelTerminalTextForTesting(blockID: blockID)
+ }
+
+ var commandOutputPanelCommandLineTextForTesting: String? {
+ logScrollView.commandOutputPanelCommandLineTextForTesting
+ }
+
+ var commandOutputPanelOutputScrollTextForTesting: String? {
+ logScrollView.commandOutputPanelOutputScrollTextForTesting
+ }
+
+ var commandOutputPanelOutputScrollIsScrollableForTesting: Bool {
+ logScrollView.commandOutputPanelOutputScrollIsScrollableForTesting
+ }
+
+ var commandOutputPanelOutputScrollUsesHorizontalScrollingForTesting: Bool {
+ logScrollView.commandOutputPanelOutputScrollUsesHorizontalScrollingForTesting
+ }
+
+ var commandOutputPanelOutputScrollVerticalOffsetForTesting: CGFloat? {
+ logScrollView.commandOutputPanelOutputScrollVerticalOffsetForTesting
+ }
+
+ var commandOutputPanelOutputScrollMaximumVerticalOffsetForTesting: CGFloat? {
+ logScrollView.commandOutputPanelOutputScrollMaximumVerticalOffsetForTesting
+ }
+
+ func scrollCommandOutputPanelOutputForTesting(deltaY: CGFloat) -> Bool {
+ logScrollView.scrollCommandOutputPanelOutputForTesting(deltaY: deltaY)
+ }
+
+ var commandOutputPanelOutputHitTestTargetsTextViewForTesting: Bool {
+ logScrollView.commandOutputPanelOutputHitTestTargetsTextViewForTesting
+ }
+
+ func finderRectsForTesting(_ range: NSRange) -> [NSRect] {
+ logScrollView.finderRectsForTesting(range)
+ }
+
+ var firstCommandOutputPanelRectForTesting: NSRect? {
+ logScrollView.firstCommandOutputPanelRectForTesting
+ }
+
+ var commandOutputPanelToggleSymbolNameForTesting: String? {
+ logScrollView.commandOutputPanelToggleSymbolNameForTesting
+ }
+
+ var commandOutputPanelLeadingAlignmentDeltaForTesting: CGFloat? {
+ logScrollView.commandOutputPanelLeadingAlignmentDeltaForTesting
+ }
+
+ var commandOutputPanelChevronSizeDeltaForTesting: CGFloat? {
+ logScrollView.commandOutputPanelChevronSizeDeltaForTesting
+ }
+
+ var commandOutputPanelChevronVerticalAlignmentDeltaForTesting: CGFloat? {
+ logScrollView.commandOutputPanelChevronVerticalAlignmentDeltaForTesting
+ }
+
+ func hitTestTargetsDocumentViewForFirstLogOccurrenceForTesting(_ text: String) -> Bool {
+ logScrollView.hitTestTargetsDocumentViewForFirstLogOccurrenceForTesting(text)
+ }
+
+ func toggleFirstCommandOutputPanelForTesting() {
+ logScrollView.toggleFirstCommandOutputPanelForTesting()
+ }
+
+ @discardableResult
+ func clickFirstCommandOutputPanelHeaderForTesting() -> Bool {
+ logScrollView.clickFirstCommandOutputPanelHeaderForTesting()
+ }
+
+ @discardableResult
+ func clickCommandOutputPanelHeaderForTesting(blockID: ReviewMonitorLog.BlockID) -> Bool {
+ logScrollView.clickCommandOutputPanelHeaderForTesting(blockID: blockID)
+ }
+
+ func completeWordGlowAnimationsForTesting() {
+ logScrollView.completeWordGlowAnimationsForTesting()
+ }
+
+ func advanceWordGlowAnimationsAfterInitialDelayForTesting(_ delay: TimeInterval) {
+ logScrollView.advanceWordGlowAnimationsAfterInitialDelayForTesting(delay)
+ }
+
+ func setReduceMotionForTesting(_ reduceMotion: Bool?) {
+ logScrollView.setReduceMotionForTesting(reduceMotion)
+ }
+
+ var usesCustomTextKit2SurfaceForTesting: Bool {
+ logScrollView.usesCustomTextKit2SurfaceForTesting
+ }
+
+ var usesTextViewForTesting: Bool {
+ logScrollView.usesTextViewForTesting
+ }
+
+ var usesLogLayoutManagerForTesting: Bool {
+ logScrollView.usesLogLayoutManagerForTesting
+ }
+
+ var isEditableForTesting: Bool {
+ logScrollView.isEditableForTesting
+ }
+
+ var isSelectableForTesting: Bool {
+ logScrollView.isSelectableForTesting
+ }
+
+ var usesFindBarForTesting: Bool {
+ logScrollView.usesFindBarForTesting
+ }
+
+ var isIncrementalSearchingEnabledForTesting: Bool {
+ logScrollView.isIncrementalSearchingEnabledForTesting
+ }
+
+ var isFindBarVisibleForTesting: Bool {
+ logScrollView.isFindBarVisibleForTesting
+ }
+
+ var textFinderIdentifierForTesting: ObjectIdentifier {
+ logScrollView.textFinderIdentifierForTesting
+ }
+
+ var findVisibleCharacterRangesForTesting: [NSRange] {
+ logScrollView.findVisibleCharacterRangesForTesting
+ }
+
+ var findStringLengthForTesting: Int {
+ logScrollView.findStringLengthForTesting
+ }
+
+ var findStringForTesting: String {
+ logScrollView.findStringForTesting
+ }
+
+ var findClientUsesSnapshotForTesting: Bool {
+ logScrollView.findClientUsesSnapshotForTesting
+ }
+
+ var findClientSnapshotMapsToDocumentForTesting: Bool {
+ logScrollView.findClientSnapshotMapsToDocumentForTesting
+ }
+
+ var findClientFirstSelectedRangeForTesting: NSRange {
+ logScrollView.findClientFirstSelectedRangeForTesting
+ }
+
+ var hasActiveFindQueryForTesting: Bool {
+ logScrollView.hasActiveFindQueryForTesting
+ }
+
+ var visibleFindBarSearchStringForTesting: String? {
+ logScrollView.visibleFindBarSearchStringForTesting
+ }
+
+ @discardableResult
+ func setVisibleFindBarSearchStringForTesting(_ string: String) -> Bool {
+ logScrollView.setVisibleFindBarSearchStringForTesting(string)
+ }
+
+ var findIndicatorInvalidationCountForTesting: Int {
+ logScrollView.findIndicatorInvalidationCountForTesting
+ }
+
+ var findIncrementalMatchRangeCountForTesting: Int {
+ logScrollView.findIncrementalMatchRangeCountForTesting
+ }
+
+ var findBarContainerContentViewIsTextContentViewForTesting: Bool {
+ logScrollView.findBarContainerContentViewIsTextContentViewForTesting
+ }
+
+ var findIncrementalSearchUsesSystemHighlightingForTesting: Bool {
+ logScrollView.findIncrementalSearchUsesSystemHighlightingForTesting
+ }
+
+ var hitTestTargetsDocumentViewForTesting: Bool {
+ logScrollView.hitTestTargetsDocumentViewForTesting
+ }
+
+ var writingToolsDisabledForTesting: Bool {
+ logScrollView.writingToolsDisabledForTesting
+ }
+
+ var overlayScrollerHideRequestCountForTesting: Int {
+ logScrollView.overlayScrollerHideRequestCountForTesting
+ }
+
+ var frame: NSRect {
+ logScrollView.frame
+ }
+
+ var verticalScrollOffsetForTesting: CGFloat {
+ logScrollView.verticalScrollOffsetForTesting
+ }
+
+ var viewportHeightForTesting: CGFloat {
+ logScrollView.viewportHeightForTesting
+ }
+
+ var minimumVerticalScrollOffsetForTesting: CGFloat {
+ logScrollView.minimumVerticalScrollOffsetForTesting
+ }
+
+ var maximumVerticalScrollOffsetForTesting: CGFloat {
+ logScrollView.maximumVerticalScrollOffsetForTesting
+ }
+
+ var textContentFrameForTesting: NSRect {
+ logScrollView.textContentFrameForTesting
+ }
+
+ var documentViewFrameForTesting: NSRect {
+ logScrollView.documentViewFrameForTesting
+ }
+
+ var contentInsetsForTesting: NSEdgeInsets {
+ logScrollView.contentInsetsForTesting
+ }
+
+ var automaticallyAdjustsContentInsetsForTesting: Bool {
+ logScrollView.automaticallyAdjustsContentInsetsForTesting
+ }
+
+ var textContainerSizeForTesting: NSSize {
+ logScrollView.textContainerSizeForTesting
+ }
+
+ var textContainerInsetForTesting: NSSize {
+ logScrollView.textContainerInsetForTesting
+ }
+
+ var visibleFragmentViewCountForTesting: Int {
+ logScrollView.visibleFragmentViewCountForTesting
+ }
+
+ var visibleFragmentViewCountWithoutForcingLayoutForTesting: Int {
+ logScrollView.visibleFragmentViewCountWithoutForcingLayoutForTesting
+ }
+
+ var visibleFragmentBoundsForTesting: NSRect {
+ logScrollView.visibleFragmentBoundsForTesting
+ }
+
+ var visibleFragmentBoundsWithoutForcingLayoutForTesting: NSRect {
+ logScrollView.visibleFragmentBoundsWithoutForcingLayoutForTesting
+ }
+
+ var staleFragmentViewCountForTesting: Int {
+ logScrollView.staleFragmentViewCountForTesting
+ }
+
+ var programmaticScrollCountForTesting: Int {
+ logScrollView.programmaticScrollCountForTesting
+ }
+
+ var accessibilityValueForTesting: String? {
+ logScrollView.accessibilityValueForTesting
+ }
+
+ var selectedTextForTesting: String? {
+ logScrollView.selectedTextForTesting
+ }
+
+ var selectedRangeForTesting: NSRange {
+ logScrollView.selectedRangeForTesting
+ }
+
+ func scrollToTopForTesting() {
+ logScrollView.scrollToTopForTesting()
+ }
+
+ func scrollToOffsetForTesting(_ y: CGFloat) {
+ logScrollView.scrollToOffsetForTesting(y)
+ }
+
+ func scrollToBottomForTesting() {
+ logScrollView.scrollToBottomForTesting()
+ }
+
+ func selectAllForTesting() {
+ logScrollView.selectAllForTesting()
+ }
+
+ func setSelectedLogRangeForTesting(_ range: NSRange) {
+ logScrollView.setSelectedLogRangeForTesting(range)
+ }
+
+ var documentViewExportsUserInterfaceValidationForTesting: Bool {
+ logScrollView.documentViewExportsUserInterfaceValidationForTesting
+ }
+
+ func validateDocumentUserInterfaceItemForTesting(_ item: NSValidatedUserInterfaceItem) -> Bool {
+ logScrollView.validateDocumentUserInterfaceItemForTesting(item)
+ }
+
+ func clearFinderSelectedRangesForTesting() {
+ logScrollView.clearFinderSelectedRangesForTesting()
+ }
+
+ func setFinderSelectedRangeForTesting(_ range: NSRange) {
+ logScrollView.setFinderSelectedRangeForTesting(range)
+ }
+
+ func simulateFinderEmptySelectedRangesForTesting() {
+ logScrollView.simulateFinderEmptySelectedRangesForTesting()
+ }
+
+ func performKeyboardCommandForTesting(_ selector: Selector) {
+ logScrollView.performKeyboardCommandForTesting(selector)
+ }
+
+ @discardableResult
+ func renderForTesting(text: String, allowIncrementalUpdate: Bool) -> Bool {
+ logScrollView.renderForTesting(text: text, allowIncrementalUpdate: allowIncrementalUpdate)
+ }
+
+ func copySelectionForTesting() {
+ logScrollView.copySelectionForTesting()
+ }
+
+ var isPinnedToBottomForTesting: Bool {
+ logScrollView.isPinnedToBottomForTesting
+ }
+
+ func setScrollerStyleForTesting(_ style: NSScroller.Style) {
+ logScrollView.setScrollerStyleForTesting(style)
+ }
+
+ func setOverlayScrollersShownForTesting(_ isShown: Bool?) {
+ logScrollView.setOverlayScrollersShownForTesting(isShown)
+ }
+
+ func setOverlayScrollerBridgeModeForTesting(_ mode: OverlayScrollerBridgeModeForTesting) {
+ let scrollViewMode: ReviewMonitorLogScrollView.OverlayScrollerBridgeModeForTesting
+ switch mode {
+ case .live:
+ scrollViewMode = .live
+ case .missingScrollerImpPair:
+ scrollViewMode = .missingScrollerImpPair
+ case .missingOverlayScrollersShown:
+ scrollViewMode = .missingOverlayScrollersShown
+ case .missingHideMethods:
+ scrollViewMode = .missingHideMethods
+ }
+ logScrollView.setOverlayScrollerBridgeModeForTesting(scrollViewMode)
+ }
+
+ func beginLiveResizeForTesting() {
+ logScrollView.beginLiveResizeForTesting()
+ }
+
+ func endLiveResizeForTesting() {
+ logScrollView.endLiveResizeForTesting()
+ }
+
+ @discardableResult
+ func renderLogDocumentForTesting(
+ _ sourceDocument: ReviewMonitorLog.Document,
+ chatID: CodexThreadID?,
+ allowIncrementalUpdate: Bool
+ ) -> Bool {
+ let resolvedChatID: CodexThreadID
+ if let chatID {
+ resolvedChatID = chatID
+ } else if let boundChatID {
+ resolvedChatID = boundChatID
+ } else {
+ return false
+ }
+ let resolvedTarget = LogRenderTarget.chat(resolvedChatID)
+ let resolvedRestorationTarget = hasAppliedBoundLog
+ ? logScrollView.currentScrollRestorationTarget
+ : restorationTarget(chatID: resolvedChatID)
+ return renderBoundLog(
+ sourceDocument: sourceDocument,
+ target: resolvedTarget,
+ restorationTarget: resolvedRestorationTarget,
+ allowIncrementalUpdate: allowIncrementalUpdate
+ )
+ }
+
+ func bindLogRenderTargetForTesting(_ chatID: CodexThreadID) {
+ if boundChatID != chatID {
+ let isSwitchingRenderedChat = boundChatID != nil
+ cacheBoundLogScrollTarget()
+ if isSwitchingRenderedChat {
+ logScrollView.resetFindStateForContentReuse()
+ }
+ selectedChatLogTask?.cancel()
+ selectedChatLogTask = nil
+ cancelSelectedChatObservation()
+ cancelPendingLogSourceChange()
+ resetLogRenderer()
+ boundChatID = chatID
+ boundModelContext = nil
+ boundChat = nil
+ }
+ logScrollView.isHidden = false
+ }
+ }
+#endif
diff --git a/Sources/ReviewMonitorRendering/ReviewTimelineDocument.swift b/Sources/ReviewMonitorRendering/ReviewTimelineDocument.swift
deleted file mode 100644
index 5a9b7415..00000000
--- a/Sources/ReviewMonitorRendering/ReviewTimelineDocument.swift
+++ /dev/null
@@ -1,435 +0,0 @@
-import Foundation
-import CodexReviewDomain
-
-public struct ReviewTimelineDocument: Codable, Equatable, Sendable {
- public struct Block: Identifiable, Codable, Equatable, Sendable {
- public struct ID: RawRepresentable, Codable, Hashable, Sendable, ExpressibleByStringLiteral, CustomStringConvertible {
- public var rawValue: String
-
- public init(rawValue: String) {
- self.rawValue = rawValue
- }
-
- public init(stringLiteral value: String) {
- self.init(rawValue: value)
- }
-
- public init(itemID: ReviewTimelineItem.ID) {
- self.init(rawValue: itemID.rawValue)
- }
-
- public var description: String {
- rawValue
- }
- }
-
- public var id: ID
- public var sourceItemID: ReviewTimelineItem.ID
- public var kind: ReviewItemKind
- public var family: ReviewItemFamily
- public var phase: ReviewItemPhase
- public var isActive: Bool
- public var primaryText: String
- public var rawTranscriptText: String
- public var content: Content
- public var createdAt: Date
- public var updatedAt: Date
- public var startedAt: Date?
- public var completedAt: Date?
- public var durationMs: Int?
-
- public init(
- id: ID,
- sourceItemID: ReviewTimelineItem.ID,
- kind: ReviewItemKind,
- family: ReviewItemFamily,
- phase: ReviewItemPhase,
- isActive: Bool,
- primaryText: String,
- rawTranscriptText: String,
- content: Content,
- createdAt: Date,
- updatedAt: Date,
- startedAt: Date? = nil,
- completedAt: Date? = nil,
- durationMs: Int? = nil
- ) {
- self.id = id
- self.sourceItemID = sourceItemID
- self.kind = kind
- self.family = family
- self.phase = phase
- self.isActive = isActive
- self.primaryText = primaryText
- self.rawTranscriptText = rawTranscriptText
- self.content = content
- self.createdAt = createdAt
- self.updatedAt = updatedAt
- self.startedAt = startedAt
- self.completedAt = completedAt
- self.durationMs = durationMs
- }
- }
-
- public enum Content: Codable, Equatable, Sendable {
- case approval(Approval)
- case command(Command)
- case contextCompaction(ContextCompaction)
- case diagnostic(Diagnostic)
- case fileChange(FileChange)
- case message(Message)
- case plan(Plan)
- case reasoning(Reasoning)
- case search(Search)
- case toolCall(ToolCall)
- case unknown(Unknown)
-
- public var type: String {
- switch self {
- case .approval:
- "approval"
- case .command:
- "command"
- case .contextCompaction:
- "contextCompaction"
- case .diagnostic:
- "diagnostic"
- case .fileChange:
- "fileChange"
- case .message:
- "message"
- case .plan:
- "plan"
- case .reasoning:
- "reasoning"
- case .search:
- "search"
- case .toolCall:
- "toolCall"
- case .unknown:
- "unknown"
- }
- }
- }
-
- public struct Approval: Codable, Equatable, Sendable {
- public var title: String
- public var detail: String?
- public var decision: ReviewApprovalDecision?
- public var scope: ReviewApprovalScope?
- public var risk: ReviewApprovalRisk?
- public var status: ReviewApprovalStatus?
-
- public init(
- title: String,
- detail: String? = nil,
- decision: ReviewApprovalDecision? = nil,
- scope: ReviewApprovalScope? = nil,
- risk: ReviewApprovalRisk? = nil,
- status: ReviewApprovalStatus? = nil
- ) {
- self.title = title
- self.detail = detail
- self.decision = decision
- self.scope = scope
- self.risk = risk
- self.status = status
- }
- }
-
- public struct Command: Codable, Equatable, Sendable {
- public struct Action: Codable, Equatable, Sendable {
- public var kind: ReviewCommandActionKind
- public var command: String?
- public var name: String?
- public var path: String?
- public var query: String?
-
- public init(
- kind: ReviewCommandActionKind,
- 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 title: String
- public var command: String
- public var cwd: String?
- public var output: String
- public var exitCode: Int?
- public var status: ReviewCommandStatus?
- public var source: ReviewCommandSource?
- public var processID: String?
- public var actions: [Action]
- public var durationMs: Int?
-
- public init(
- title: String,
- command: String,
- cwd: String? = nil,
- output: String = "",
- exitCode: Int? = nil,
- status: ReviewCommandStatus? = nil,
- source: ReviewCommandSource? = nil,
- processID: String? = nil,
- actions: [Action] = [],
- durationMs: Int? = nil
- ) {
- self.title = title
- self.command = command
- self.cwd = cwd
- self.output = output
- self.exitCode = exitCode
- self.status = status
- self.source = source
- self.processID = processID
- self.actions = actions
- self.durationMs = durationMs
- }
- }
-
- public struct ContextCompaction: Codable, Equatable, Sendable {
- public var title: String
- public var status: ReviewContextCompactionStatus?
- public var inputTokens: Int?
- public var outputTokens: Int?
-
- public init(
- title: String,
- status: ReviewContextCompactionStatus? = nil,
- inputTokens: Int? = nil,
- outputTokens: Int? = nil
- ) {
- self.title = title
- self.status = status
- self.inputTokens = inputTokens
- self.outputTokens = outputTokens
- }
- }
-
- public struct Diagnostic: Codable, Equatable, Sendable {
- public struct Retry: Codable, Equatable, Sendable {
- public var state: ReviewDiagnosticRetryState
- public var attempt: Int?
- public var maxAttempts: Int?
- public var delayMs: Int?
-
- public init(
- state: ReviewDiagnosticRetryState,
- attempt: Int? = nil,
- maxAttempts: Int? = nil,
- delayMs: Int? = nil
- ) {
- self.state = state
- self.attempt = attempt
- self.maxAttempts = maxAttempts
- self.delayMs = delayMs
- }
- }
-
- public var message: String
- public var severity: ReviewDiagnosticSeverity?
- public var retry: Retry?
-
- public init(
- message: String,
- severity: ReviewDiagnosticSeverity? = nil,
- retry: Retry? = nil
- ) {
- self.message = message
- self.severity = severity
- self.retry = retry
- }
- }
-
- public struct FileChange: Codable, Equatable, Sendable {
- public var title: String
- public var output: String
- public var paths: [String]
- public var patch: String?
- public var status: ReviewFileChangeStatus?
-
- public init(
- title: String,
- output: String = "",
- paths: [String] = [],
- patch: String? = nil,
- status: ReviewFileChangeStatus? = nil
- ) {
- self.title = title
- self.output = output
- self.paths = paths
- self.patch = patch
- self.status = status
- }
- }
-
- public struct Message: Codable, Equatable, Sendable {
- public var text: String
-
- public init(text: String) {
- self.text = text
- }
- }
-
- public struct Plan: Codable, Equatable, Sendable {
- public var markdown: String
-
- public init(markdown: String) {
- self.markdown = markdown
- }
- }
-
- public struct Reasoning: Codable, Equatable, Sendable {
- public var text: String
- public var style: ReviewTimelineItem.Reasoning.Style
-
- public init(text: String, style: ReviewTimelineItem.Reasoning.Style) {
- self.text = text
- self.style = style
- }
- }
-
- public struct Search: Codable, Equatable, Sendable {
- public var query: String
- public var result: String?
- public var status: ReviewSearchStatus?
- public var resultCount: Int?
- public var durationMs: Int?
-
- public init(
- query: String,
- result: String? = nil,
- status: ReviewSearchStatus? = nil,
- resultCount: Int? = nil,
- durationMs: Int? = nil
- ) {
- self.query = query
- self.result = result
- self.status = status
- self.resultCount = resultCount
- self.durationMs = durationMs
- }
- }
-
- public struct ToolCall: Codable, 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: ReviewToolCallStatus?
- public var durationMs: Int?
- public var appContext: ReviewAppContext?
- public var pluginID: ReviewPluginID?
- public var callID: ReviewToolCall.ID?
- public var progress: String?
-
- public init(
- namespace: String? = nil,
- server: String? = nil,
- name: String? = nil,
- arguments: String? = nil,
- result: String? = nil,
- error: String? = nil,
- status: ReviewToolCallStatus? = nil,
- durationMs: Int? = nil,
- appContext: ReviewAppContext? = nil,
- pluginID: ReviewPluginID? = nil,
- callID: ReviewToolCall.ID? = nil,
- progress: String? = nil
- ) {
- self.namespace = namespace
- self.server = server
- self.name = name
- self.arguments = arguments
- self.result = result
- self.error = error
- self.status = status
- self.durationMs = durationMs
- self.appContext = appContext
- self.pluginID = pluginID
- self.callID = callID
- self.progress = progress
- }
- }
-
- public struct Unknown: Codable, Equatable, Sendable {
- public struct RawReference: Codable, Equatable, Sendable {
- public var kind: ReviewRawReferenceKind
- public var value: String
- public var label: String?
-
- public init(kind: ReviewRawReferenceKind, value: String, label: String? = nil) {
- self.kind = kind
- self.value = value
- self.label = label
- }
- }
-
- public var title: String
- public var detail: String?
- public var rawKind: ReviewItemKind?
- public var rawStatus: String?
- public var references: [RawReference]
-
- public init(
- title: String,
- detail: String? = nil,
- rawKind: ReviewItemKind? = nil,
- rawStatus: String? = nil,
- references: [RawReference] = []
- ) {
- self.title = title
- self.detail = detail
- self.rawKind = rawKind
- self.rawStatus = rawStatus
- self.references = references
- }
- }
-
- public var timelineRevision: ReviewTimeline.Revision
- public var orderedBlockIDs: [Block.ID]
- public var activeBlockIDs: [Block.ID]
- public var activeBlockCount: Int
- public var latestActivityBlockID: Block.ID?
- public var terminalStatus: ReviewLifecycleStatus?
- public var terminalSummary: String?
- public var terminalResult: String?
- public var blocks: [Block]
-
- public init(
- timelineRevision: ReviewTimeline.Revision,
- orderedBlockIDs: [Block.ID],
- activeBlockIDs: [Block.ID],
- activeBlockCount: Int,
- latestActivityBlockID: Block.ID?,
- terminalStatus: ReviewLifecycleStatus?,
- terminalSummary: String?,
- terminalResult: String?,
- blocks: [Block]
- ) {
- self.timelineRevision = timelineRevision
- self.orderedBlockIDs = orderedBlockIDs
- self.activeBlockIDs = activeBlockIDs
- self.activeBlockCount = activeBlockCount
- self.latestActivityBlockID = latestActivityBlockID
- self.terminalStatus = terminalStatus
- self.terminalSummary = terminalSummary
- self.terminalResult = terminalResult
- self.blocks = blocks
- }
-
- public var plainText: String {
- blocks.map(\.rawTranscriptText).filter { $0.isEmpty == false }.joined(separator: "\n\n")
- }
-}
diff --git a/Sources/ReviewMonitorRendering/ReviewTimelineDocumentRenderer.swift b/Sources/ReviewMonitorRendering/ReviewTimelineDocumentRenderer.swift
deleted file mode 100644
index 7ad38420..00000000
--- a/Sources/ReviewMonitorRendering/ReviewTimelineDocumentRenderer.swift
+++ /dev/null
@@ -1,236 +0,0 @@
-import Foundation
-import CodexReviewDomain
-
-public struct ReviewTimelineDocumentRenderer: Sendable {
- public init() {}
-
- @MainActor
- public func document(from timeline: ReviewTimeline) -> ReviewTimelineDocument {
- let activeBlockIDs = timeline.orderedItemIDs
- .filter { timeline.activeItemIDs.contains($0) }
- .map(ReviewTimelineDocument.Block.ID.init(itemID:))
- let blocks = timeline.items.map { item in
- Self.block(for: item, isActive: timeline.activeItemIDs.contains(item.id))
- }
- return ReviewTimelineDocument(
- timelineRevision: timeline.revision,
- orderedBlockIDs: blocks.map(\.id),
- activeBlockIDs: activeBlockIDs,
- activeBlockCount: activeBlockIDs.count,
- latestActivityBlockID: timeline.latestActivity.map(ReviewTimelineDocument.Block.ID.init(itemID:)),
- terminalStatus: timeline.terminalStatus,
- terminalSummary: timeline.terminalSummary,
- terminalResult: timeline.terminalResult,
- blocks: blocks
- )
- }
-
- @MainActor
- public func plainText(from timeline: ReviewTimeline) -> String {
- document(from: timeline).plainText
- }
-
- @MainActor
- private static func block(for item: ReviewTimelineItem, isActive: Bool) -> ReviewTimelineDocument.Block {
- let content = Self.content(for: item.content)
- return ReviewTimelineDocument.Block(
- id: .init(itemID: item.id),
- sourceItemID: item.id,
- kind: item.kind,
- family: item.family,
- phase: item.phase,
- isActive: isActive,
- primaryText: Self.primaryText(for: content),
- rawTranscriptText: Self.rawTranscriptText(for: content),
- content: content,
- createdAt: item.createdAt,
- updatedAt: item.updatedAt,
- startedAt: item.startedAt,
- completedAt: item.completedAt,
- durationMs: item.durationMs
- )
- }
-
- private static func content(for content: ReviewTimelineItem.Content) -> ReviewTimelineDocument.Content {
- switch content {
- case .approval(let approval):
- return .approval(.init(
- title: approval.title,
- detail: approval.detail,
- decision: approval.decision,
- scope: approval.scope,
- risk: approval.risk,
- status: approval.status
- ))
- case .command(let command):
- return .command(.init(
- title: command.command,
- command: command.command,
- cwd: command.cwd,
- output: command.output,
- exitCode: command.exitCode,
- status: command.status,
- source: command.source,
- processID: command.processID,
- actions: command.actions.map {
- .init(
- kind: $0.kind,
- command: $0.command,
- name: $0.name,
- path: $0.path,
- query: $0.query
- )
- },
- durationMs: command.durationMs
- ))
- case .contextCompaction(let contextCompaction):
- return .contextCompaction(.init(
- title: contextCompaction.title,
- status: contextCompaction.status,
- inputTokens: contextCompaction.inputTokens,
- outputTokens: contextCompaction.outputTokens
- ))
- case .diagnostic(let diagnostic):
- return .diagnostic(.init(
- message: diagnostic.message,
- severity: diagnostic.severity,
- retry: diagnostic.retry.map {
- .init(
- state: $0.state,
- attempt: $0.attempt,
- maxAttempts: $0.maxAttempts,
- delayMs: $0.delayMs
- )
- }
- ))
- case .fileChange(let fileChange):
- return .fileChange(.init(
- title: fileChange.title,
- output: fileChange.output,
- paths: fileChange.paths,
- patch: fileChange.patch,
- status: fileChange.status
- ))
- case .message(let message):
- return .message(.init(text: message.text))
- case .plan(let plan):
- return .plan(.init(markdown: plan.markdown))
- case .reasoning(let reasoning):
- return .reasoning(.init(text: reasoning.text, style: reasoning.style))
- case .search(let search):
- return .search(.init(
- query: search.query,
- result: search.result,
- status: search.status,
- resultCount: search.resultCount,
- durationMs: search.durationMs
- ))
- case .toolCall(let toolCall):
- return .toolCall(.init(
- namespace: toolCall.namespace,
- server: toolCall.server,
- name: toolCall.tool,
- arguments: toolCall.arguments,
- result: toolCall.result,
- error: toolCall.error,
- status: toolCall.status,
- durationMs: toolCall.durationMs,
- appContext: toolCall.appContext,
- pluginID: toolCall.pluginID,
- callID: toolCall.callID,
- progress: toolCall.progress
- ))
- case .unknown(let unknown):
- return .unknown(.init(
- title: unknown.title,
- detail: unknown.detail,
- rawKind: unknown.rawKind,
- rawStatus: unknown.rawStatus,
- references: unknown.references.map {
- .init(kind: $0.kind, value: $0.value, label: $0.label)
- }
- ))
- }
- }
-
- private static func primaryText(for content: ReviewTimelineDocument.Content) -> String {
- switch content {
- case .approval(let approval):
- return approval.title
- case .command(let command):
- return command.title
- case .contextCompaction(let contextCompaction):
- return contextCompaction.title
- case .diagnostic(let diagnostic):
- return diagnostic.message
- case .fileChange(let fileChange):
- return fileChange.title
- case .message(let message):
- return message.text
- case .plan(let plan):
- return plan.markdown
- case .reasoning(let reasoning):
- return reasoning.text
- case .search(let search):
- return search.query
- case .toolCall(let toolCall):
- return toolCallLabel(for: toolCall)
- case .unknown(let unknown):
- return unknown.title
- }
- }
-
- private static func rawTranscriptText(for content: ReviewTimelineDocument.Content) -> String {
- switch content {
- case .approval(let approval):
- return [approval.title, approval.detail].compactMap { $0 }.joined(separator: "\n")
- case .command(let command):
- guard command.command.isEmpty == false else {
- return command.output
- }
- return command.output.isEmpty ? "$ \(command.command)" : "$ \(command.command)\n\(command.output)"
- case .contextCompaction(let contextCompaction):
- return contextCompaction.title
- case .diagnostic(let diagnostic):
- return diagnostic.message
- case .fileChange(let fileChange):
- return fileChange.output.isEmpty ? fileChange.title : "\(fileChange.title)\n\(fileChange.output)"
- case .message(let message):
- return message.text
- case .plan(let plan):
- return plan.markdown
- case .reasoning(let reasoning):
- return reasoning.text
- case .search(let search):
- return [search.query, search.result].compactMap { $0 }.joined(separator: "\n")
- case .toolCall(let toolCall):
- return toolCallTranscriptText(for: toolCall)
- case .unknown(let unknown):
- return [unknown.title, unknown.detail].compactMap { $0 }.joined(separator: "\n")
- }
- }
-
- private static func toolCallLabel(for toolCall: ReviewTimelineDocument.ToolCall) -> String {
- [toolCall.namespace, toolCall.server, toolCall.name]
- .compactMap { nonEmptyText($0) }
- .joined(separator: ".")
- }
-
- private static func toolCallTranscriptText(for toolCall: ReviewTimelineDocument.ToolCall) -> String {
- [
- toolCallLabel(for: toolCall),
- toolCall.progress,
- toolCall.result,
- toolCall.error,
- ]
- .compactMap { nonEmptyText($0) }
- .joined(separator: "\n")
- }
-
- private static func nonEmptyText(_ text: String?) -> String? {
- guard let text, text.isEmpty == false else {
- return nil
- }
- return text
- }
-}
diff --git a/Sources/ReviewUI/Detail/ReviewMonitorLogProjection.swift b/Sources/ReviewUI/Detail/ReviewMonitorLogProjection.swift
deleted file mode 100644
index 0faff050..00000000
--- a/Sources/ReviewUI/Detail/ReviewMonitorLogProjection.swift
+++ /dev/null
@@ -1,1657 +0,0 @@
-import Foundation
-import CodexReview
-
-enum ReviewMonitorLog {}
-
-extension ReviewMonitorLog {
-struct BlockID: Codable, Hashable, Sendable {
- var rawValue: String
-
- init(_ rawValue: String) {
- self.rawValue = rawValue
- }
-}
-
-struct Block: Equatable, Sendable {
- var id: ReviewMonitorLog.BlockID
- var kind: ReviewLogEntry.Kind
- var groupID: String?
- var range: NSRange
- var sourceRange: NSRange
- var metadata: ReviewLogEntry.Metadata?
-
- init(
- id: ReviewMonitorLog.BlockID,
- kind: ReviewLogEntry.Kind,
- groupID: String?,
- range: NSRange,
- sourceRange: NSRange? = nil,
- metadata: ReviewLogEntry.Metadata? = nil
- ) {
- self.id = id
- self.kind = kind
- self.groupID = groupID
- self.range = range
- self.sourceRange = sourceRange ?? range
- self.metadata = metadata
- }
-}
-
-enum StatusTone: Hashable, Sendable {
- case neutral
- case running
- case success
- case warning
- case failure
-}
-
-enum PlanStatus: String, Hashable, Sendable {
- case pending
- case inProgress
- case completed
- case failed
-}
-
-enum TextStyle: Hashable, Sendable {
- case body
- case heading(level: Int)
- case bullet
- case blockquote
- case strong
- case emphasis
- case link
- case strikethrough
- case inlineCode
- case codeFence
- case markdownSyntax
- case command
- case terminalOutput
- case commandOutputControl(keepsTrailingContent: Bool)
- case plan(status: ReviewMonitorLog.PlanStatus?)
- case tool
- case diagnostic
- case error
- case event
- case contextCompaction
- case muted
-}
-
-struct TextRun: Equatable, Sendable {
- var range: NSRange
- var style: ReviewMonitorLog.TextStyle
-}
-
-struct AnimationSpan: Equatable, Sendable {
- enum Kind: Equatable, Sendable {
- case wordFade
- }
-
- var kind: Kind
- var range: NSRange
-}
-
-enum DecorationStyle: Hashable, Sendable {
- case transcript
- case command(tone: ReviewMonitorLog.StatusTone)
- case terminal(tone: ReviewMonitorLog.StatusTone)
- case codeBlock
- case plan(tone: ReviewMonitorLog.StatusTone)
- case reasoning
- case tool(tone: ReviewMonitorLog.StatusTone)
- case diagnostic(tone: ReviewMonitorLog.StatusTone)
- case error
- case event
- case contextCompaction(label: String, isCompleted: Bool)
-}
-
-struct Decoration: Equatable, Sendable {
- var blockID: ReviewMonitorLog.BlockID
- var range: NSRange
- var style: ReviewMonitorLog.DecorationStyle
-}
-
-struct CommandOutputPanel: Equatable, Sendable {
- var blockID: ReviewMonitorLog.BlockID
- var range: NSRange
- var commandText: String
- var outputText: String
- var outputSourceRange: NSRange? = nil
- var lineCount: Int
- var isExpanded: Bool
- var isActive: Bool
- var startedAt: Date?
- var title: String
- var exitText: String?
-}
-
-struct Append: Equatable, Sendable {
- var kind: ReviewLogEntry.Kind
- var blockID: ReviewMonitorLog.BlockID
- var range: NSRange
- var text: String
- var textUTF16Length: Int
- var animationSpans: [ReviewMonitorLog.AnimationSpan]
-
- init(
- kind: ReviewLogEntry.Kind,
- blockID: ReviewMonitorLog.BlockID,
- range: NSRange,
- text: String,
- textUTF16Length: Int? = nil,
- animationSpans: [ReviewMonitorLog.AnimationSpan] = []
- ) {
- self.kind = kind
- self.blockID = blockID
- self.range = range
- self.text = text
- self.textUTF16Length = textUTF16Length ?? Self.utf16Length(text)
- self.animationSpans = animationSpans
- }
-
- private static func utf16Length(_ text: String) -> Int {
- (text as NSString).length
- }
-
- static func animationSpans(
- forKind kind: ReviewLogEntry.Kind,
- absoluteRange: NSRange,
- appendBaseLocation: Int
- ) -> [ReviewMonitorLog.AnimationSpan] {
- guard Self.wordFadeKinds.contains(kind),
- absoluteRange.length > 0,
- absoluteRange.location >= appendBaseLocation
- else {
- return []
- }
- return [.init(
- kind: .wordFade,
- range: NSRange(
- location: absoluteRange.location - appendBaseLocation,
- length: absoluteRange.length
- )
- )]
- }
-
- private static let wordFadeKinds: Set = [
- .reasoning,
- .reasoningSummary,
- .rawReasoning,
- ]
-}
-
-struct Replacement: Equatable, Sendable {
- var kind: ReviewLogEntry.Kind
- var blockID: ReviewMonitorLog.BlockID
- var range: NSRange
- var text: String
- var textUTF16Length: Int
-
- init(
- kind: ReviewLogEntry.Kind,
- blockID: ReviewMonitorLog.BlockID,
- range: NSRange,
- text: String,
- textUTF16Length: Int? = nil
- ) {
- self.kind = kind
- self.blockID = blockID
- self.range = range
- self.text = text
- self.textUTF16Length = textUTF16Length ?? Self.utf16Length(text)
- }
-
- private static func utf16Length(_ text: String) -> Int {
- (text as NSString).length
- }
-}
-
-enum Change: Equatable, Sendable {
- case reload
- case append(ReviewMonitorLog.Append)
- case replace(ReviewMonitorLog.Replacement)
-}
-
-struct Document: Equatable, Sendable {
- var text: String
- var textUTF16Length: Int
- var sourceText: String
- var sourceTextUTF16Length: Int
- var blocks: [ReviewMonitorLog.Block]
- var styleRuns: [ReviewMonitorLog.TextRun]
- var decorations: [ReviewMonitorLog.Decoration]
- var commandOutputPanels: [ReviewMonitorLog.CommandOutputPanel]
- var revision: UInt64
- var lastChange: ReviewMonitorLog.Change
-
- init(
- text: String = "",
- textUTF16Length: Int? = nil,
- sourceText: String? = nil,
- sourceTextUTF16Length: Int? = nil,
- blocks: [ReviewMonitorLog.Block] = [],
- styleRuns: [ReviewMonitorLog.TextRun] = [],
- decorations: [ReviewMonitorLog.Decoration] = [],
- commandOutputPanels: [ReviewMonitorLog.CommandOutputPanel] = [],
- revision: UInt64 = 0,
- lastChange: ReviewMonitorLog.Change = .reload
- ) {
- self.text = text
- self.textUTF16Length = textUTF16Length ?? Self.utf16Length(text)
- self.sourceText = sourceText ?? text
- self.sourceTextUTF16Length = sourceTextUTF16Length ?? Self.utf16Length(sourceText ?? text)
- self.blocks = blocks
- self.styleRuns = styleRuns
- self.decorations = decorations
- self.commandOutputPanels = commandOutputPanels
- self.revision = revision
- self.lastChange = lastChange
- }
-
- private static func utf16Length(_ text: String) -> Int {
- (text as NSString).length
- }
-
- mutating func rebuildPresentation() {
- styleRuns.removeAll(keepingCapacity: true)
- decorations.removeAll(keepingCapacity: true)
- commandOutputPanels.removeAll(keepingCapacity: true)
- for block in blocks {
- ReviewMonitorLogStyler.appendPresentation(for: block, to: &self)
- }
- }
-
- mutating func rebuildPresentation(forBlockAt blockIndex: Int) {
- guard blocks.indices.contains(blockIndex) else {
- rebuildPresentation()
- return
- }
-
- let block = blocks[blockIndex]
- styleRuns.removeAll {
- NSIntersectionRange($0.range, block.range).length > 0
- }
- decorations.removeAll {
- $0.blockID == block.id || NSIntersectionRange($0.range, block.range).length > 0
- }
- commandOutputPanels.removeAll {
- $0.blockID == block.id || NSIntersectionRange($0.range, block.range).length > 0
- }
- ReviewMonitorLogStyler.appendPresentation(for: block, to: &self)
- }
-
- var finderSupplementSignature: Int {
- 0
- }
-}
-}
-
-enum ReviewMonitorLogStyler {
- struct Presentation {
- var text: String
- var styleRuns: [ReviewMonitorLog.TextRun] = []
- var decorations: [ReviewMonitorLog.Decoration] = []
- }
-
- static func renderedText(
- for kind: ReviewLogEntry.Kind,
- source: String,
- blockID: ReviewMonitorLog.BlockID
- ) -> String {
- presentation(for: kind, source: source, blockID: blockID).text
- }
-
- static func appendPresentation(for block: ReviewMonitorLog.Block, to document: inout ReviewMonitorLog.Document) {
- guard block.range.location >= 0,
- block.range.length >= 0,
- NSMaxRange(block.range) <= document.textUTF16Length,
- block.sourceRange.location >= 0,
- block.sourceRange.length >= 0,
- NSMaxRange(block.sourceRange) <= document.sourceTextUTF16Length
- else {
- return
- }
-
- let source = (document.sourceText as NSString).substring(with: block.sourceRange)
- let presentation = presentation(for: block.kind, source: source, blockID: block.id)
- if block.range.length > 0 {
- document.styleRuns.append(.init(range: block.range, style: baseTextStyle(for: block.kind)))
- document.decorations.append(.init(
- blockID: block.id,
- range: block.range,
- style: decorationStyle(
- for: block.kind,
- source: source,
- metadata: block.metadata
- )
- ))
- }
-
- for run in presentation.styleRuns {
- let range = offset(run.range, by: block.range.location, limitingTo: block.range.length)
- guard range.length > 0 else {
- continue
- }
- document.styleRuns.append(.init(range: range, style: run.style))
- }
- for decoration in presentation.decorations {
- let range = offset(decoration.range, by: block.range.location, limitingTo: block.range.length)
- guard range.length > 0 else {
- continue
- }
- document.decorations.append(.init(
- blockID: decoration.blockID,
- range: range,
- style: decoration.style
- ))
- }
- }
-
- private static func presentation(
- for kind: ReviewLogEntry.Kind,
- source: String,
- blockID: ReviewMonitorLog.BlockID
- ) -> Presentation {
- switch kind {
- case .agentMessage, .reasoning, .reasoningSummary, .rawReasoning:
- return renderMarkdown(source, blockID: blockID)
- case .plan, .todoList:
- return renderPlan(source)
- case .command, .commandOutput, .toolCall, .diagnostic, .error, .progress, .event, .contextCompaction:
- return .init(text: source)
- }
- }
-
- private static func baseTextStyle(for kind: ReviewLogEntry.Kind) -> ReviewMonitorLog.TextStyle {
- switch kind {
- case .agentMessage:
- .body
- case .command:
- .body
- case .commandOutput:
- .terminalOutput
- case .plan, .todoList:
- .body
- case .reasoning, .reasoningSummary, .rawReasoning:
- .body
- case .toolCall:
- .body
- case .diagnostic, .error:
- .body
- case .progress, .event:
- .event
- case .contextCompaction:
- .contextCompaction
- }
- }
-
- private static func decorationStyle(
- for kind: ReviewLogEntry.Kind,
- source: String,
- metadata: ReviewLogEntry.Metadata?
- ) -> ReviewMonitorLog.DecorationStyle {
- let tone = statusTone(for: metadata)
- switch kind {
- case .agentMessage:
- return .transcript
- case .command:
- return .command(tone: tone)
- case .commandOutput:
- return .terminal(tone: tone)
- case .plan, .todoList:
- return .plan(tone: tone)
- case .reasoning, .reasoningSummary, .rawReasoning:
- return .reasoning
- case .toolCall:
- return .tool(tone: tone)
- case .diagnostic:
- return .diagnostic(tone: tone)
- case .error:
- return .error
- case .progress, .event:
- return .event
- case .contextCompaction:
- return .contextCompaction(
- label: source,
- isCompleted: contextCompactionIsCompleted(metadata)
- )
- }
- }
-
- private static func contextCompactionIsCompleted(_ metadata: ReviewLogEntry.Metadata?) -> Bool {
- let normalized = metadata?.status?
- .lowercased()
- .replacingOccurrences(of: "_", with: "")
- .replacingOccurrences(of: "-", with: "")
- switch normalized {
- case "completed", "complete", "succeeded", "success":
- return true
- case "failed", "failure", "errored", "error", "cancelled", "canceled":
- return false
- default:
- return metadata?.completedAt != nil
- }
- }
-
- private static func statusTone(for metadata: ReviewLogEntry.Metadata?) -> ReviewMonitorLog.StatusTone {
- if let exitCode = metadata?.exitCode {
- return exitCode == 0 ? .success : .failure
- }
-
- let normalized = metadata?.status?
- .lowercased()
- .replacingOccurrences(of: "_", with: "")
- .replacingOccurrences(of: "-", with: "")
- switch normalized {
- case "started", "running", "inprogress":
- return .running
- case "completed", "complete", "succeeded", "success", "passed", "applied":
- return .success
- case "failed", "failure", "errored", "error", "cancelled", "canceled":
- return .failure
- case "warning", "warn", "updated":
- return .warning
- default:
- return .neutral
- }
- }
-
- private static func renderMarkdown(
- _ source: String,
- blockID: ReviewMonitorLog.BlockID
- ) -> Presentation {
- guard containsMarkdownSyntax(in: source) else {
- return .init(text: source)
- }
-
- var options = AttributedString.MarkdownParsingOptions()
- options.interpretedSyntax = .full
- options.failurePolicy = .returnPartiallyParsedIfPossible
-
- guard let attributed = try? AttributedString(markdown: source, options: options) else {
- return .init(text: source)
- }
-
- var builder = MarkdownPresentationBuilder(blockID: blockID)
- for run in attributed.runs {
- let text = String(attributed[run.range].characters)
- let context = MarkdownContext(presentationIntent: run.presentationIntent)
- builder.append(text, context: context, inlineIntent: run.inlinePresentationIntent, link: run.link)
- }
- let presentation = builder.finish()
- if presentation.text.isEmpty, source.isEmpty == false {
- return .init(text: source)
- }
- return presentation
- }
-
- private static func containsMarkdownSyntax(in source: String) -> Bool {
- if source.contains("```") ||
- source.contains("`") ||
- source.contains("**") ||
- source.contains("__") ||
- source.contains("~~") ||
- source.contains("](") {
- return true
- }
-
- let nsSource = source as NSString
- let fullRange = NSRange(location: 0, length: nsSource.length)
- let blockPatterns = [
- #"(?m)^\s{0,3}#{1,6}\s+"#,
- #"(?m)^\s{0,3}>\s?"#,
- #"(?m)^\s{0,3}(?:[-*+]|\d+[.)])\s+"#,
- #"(?m)^\s{0,3}(?:---+|\*\*\*+|___+)\s*$"#,
- #"(?m)^\s*\|.+\|\s*$"#,
- #"(^|[\s([{])\*[^*\n]+\*($|[\s.,;:!?)\]}])"#,
- #"(^|[\s([{])_[^_\n]+_($|[\s.,;:!?)\]}])"#,
- ]
- for pattern in blockPatterns {
- guard let expression = try? NSRegularExpression(pattern: pattern) else {
- continue
- }
- if expression.firstMatch(in: source, options: [], range: fullRange) != nil {
- return true
- }
- }
- return false
- }
-
- private static func renderPlan(_ source: String) -> Presentation {
- var result = Presentation(text: "")
- var offset = 0
- let lines = source.components(separatedBy: "\n")
- for index in lines.indices {
- if index > lines.startIndex {
- result.text += "\n"
- offset += 1
- }
-
- let line = lines[index]
- let renderedLine: String
- let status: ReviewMonitorLog.PlanStatus?
- if let parsed = planStatusAndContent(in: line) {
- status = parsed.status
- renderedLine = planMarker(for: parsed.status) + parsed.content
- } else {
- status = nil
- renderedLine = line
- }
-
- let length = utf16Length(renderedLine)
- if length > 0, status != nil {
- result.styleRuns.append(.init(
- range: NSRange(location: offset, length: length),
- style: .plan(status: status)
- ))
- }
- result.text += renderedLine
- offset += length
- }
- return result
- }
-
- private static func offset(_ range: NSRange, by location: Int, limitingTo length: Int) -> NSRange {
- let local = NSIntersectionRange(range, NSRange(location: 0, length: length))
- guard local.length > 0 else {
- return NSRange(location: location, length: 0)
- }
- return NSRange(location: location + local.location, length: local.length)
- }
-
- private static func planStatus(in line: String) -> ReviewMonitorLog.PlanStatus? {
- planStatusAndContent(in: line)?.status
- }
-
- private static func planStatusAndContent(in line: String) -> (status: ReviewMonitorLog.PlanStatus, content: String)? {
- let trimmed = line.trimmingCharacters(in: .whitespaces)
- guard trimmed.hasPrefix("["),
- let closingIndex = trimmed.firstIndex(of: "]")
- else {
- return nil
- }
- let rawStatus = trimmed[trimmed.index(after: trimmed.startIndex).. String {
- let start = line.index(after: closingIndex)
- return line[start...].trimmingCharacters(in: .whitespaces)
- }
-
- private static func planMarker(for status: ReviewMonitorLog.PlanStatus) -> String {
- switch status {
- case .completed:
- return "\u{2713} "
- case .inProgress:
- return "\u{2022} "
- case .pending:
- return "\u{25A1} "
- case .failed:
- return "! "
- }
- }
-
- private static func utf16Length(_ text: String) -> Int {
- (text as NSString).length
- }
-
- private struct MarkdownContext: Equatable {
- enum Kind: Equatable {
- case paragraph
- case heading(level: Int)
- case unorderedListItem(ordinal: Int, listIdentity: Int)
- case orderedListItem(ordinal: Int, listIdentity: Int)
- case blockquote
- case codeBlock(languageHint: String?)
- case thematicBreak
- case table
- }
-
- var identity: Int
- var kind: Kind
-
- init(presentationIntent: PresentationIntent?) {
- let components = presentationIntent?.components ?? []
- var codeBlock: (identity: Int, languageHint: String?)?
- var header: (identity: Int, level: Int)?
- var listItem: (identity: Int, ordinal: Int)?
- var orderedListIdentity: Int?
- var unorderedListIdentity: Int?
- var blockquoteIdentity: Int?
- var thematicBreakIdentity: Int?
- var paragraphIdentity: Int?
- var tableIdentity: Int?
- var tableCellIdentity: Int?
-
- for component in components {
- switch component.kind {
- case .codeBlock(let languageHint):
- if codeBlock == nil {
- codeBlock = (component.identity, languageHint)
- }
- case .header(let level):
- if header == nil {
- header = (component.identity, level)
- }
- case .listItem(let ordinal):
- if listItem == nil {
- listItem = (component.identity, ordinal)
- }
- case .orderedList:
- if orderedListIdentity == nil {
- orderedListIdentity = component.identity
- }
- case .unorderedList:
- if unorderedListIdentity == nil {
- unorderedListIdentity = component.identity
- }
- case .blockQuote:
- if blockquoteIdentity == nil {
- blockquoteIdentity = component.identity
- }
- case .thematicBreak:
- if thematicBreakIdentity == nil {
- thematicBreakIdentity = component.identity
- }
- case .paragraph:
- if paragraphIdentity == nil {
- paragraphIdentity = component.identity
- }
- case .table:
- if tableIdentity == nil {
- tableIdentity = component.identity
- }
- case .tableCell:
- if tableCellIdentity == nil {
- tableCellIdentity = component.identity
- }
- case .tableHeaderRow, .tableRow:
- break
- @unknown default:
- break
- }
- }
-
- if let codeBlock {
- self.identity = codeBlock.identity
- self.kind = .codeBlock(languageHint: codeBlock.languageHint)
- return
- }
- if let header {
- self.identity = header.identity
- self.kind = .heading(level: header.level)
- return
- }
- if let listItem {
- self.identity = listItem.identity
- if let orderedListIdentity {
- self.kind = .orderedListItem(ordinal: listItem.ordinal, listIdentity: orderedListIdentity)
- } else if let unorderedListIdentity {
- self.kind = .unorderedListItem(ordinal: listItem.ordinal, listIdentity: unorderedListIdentity)
- } else {
- self.kind = .unorderedListItem(ordinal: listItem.ordinal, listIdentity: listItem.identity)
- }
- return
- }
- if let blockquoteIdentity {
- self.identity = paragraphIdentity ?? blockquoteIdentity
- self.kind = .blockquote
- return
- }
- if let thematicBreakIdentity {
- self.identity = thematicBreakIdentity
- self.kind = .thematicBreak
- return
- }
- if let tableIdentity {
- self.identity = tableCellIdentity ?? tableIdentity
- self.kind = .table
- return
- }
-
- self.identity = paragraphIdentity ?? 0
- self.kind = .paragraph
- }
-
- var isCodeBlock: Bool {
- if case .codeBlock = kind {
- return true
- }
- return false
- }
-
- var blockStyle: ReviewMonitorLog.TextStyle? {
- switch kind {
- case .paragraph:
- return nil
- case .heading(let level):
- return .heading(level: level)
- case .unorderedListItem, .orderedListItem:
- return .bullet
- case .blockquote:
- return .blockquote
- case .codeBlock:
- return .codeFence
- case .thematicBreak:
- return .muted
- case .table:
- return .body
- }
- }
-
- func separator(after previous: MarkdownContext?) -> Int {
- guard let previous, previous != self else {
- return 0
- }
- switch (previous.kind, kind) {
- case (.unorderedListItem(_, let previousList), .unorderedListItem(_, let currentList))
- where previousList == currentList:
- return 1
- case (.orderedListItem(_, let previousList), .orderedListItem(_, let currentList))
- where previousList == currentList:
- return 1
- default:
- return 2
- }
- }
-
- var prefix: String {
- switch kind {
- case .unorderedListItem:
- return "- "
- case .orderedListItem(let ordinal, _):
- return "\(ordinal). "
- case .paragraph, .heading, .blockquote, .codeBlock, .thematicBreak, .table:
- return ""
- }
- }
- }
-
- private struct MarkdownPresentationBuilder {
- var blockID: ReviewMonitorLog.BlockID
- var text = ""
- var utf16Offset = 0
- var styleRuns: [ReviewMonitorLog.TextRun] = []
- var decorations: [ReviewMonitorLog.Decoration] = []
- var currentContext: MarkdownContext?
- var activeCodeBlockStart: Int?
-
- mutating func append(
- _ segment: String,
- context: MarkdownContext,
- inlineIntent: InlinePresentationIntent?,
- link: URL?
- ) {
- if currentContext != context {
- closeCodeBlockIfNeeded()
- appendNewlines(context.separator(after: currentContext))
- appendPrefix(for: context)
- if context.isCodeBlock {
- activeCodeBlockStart = utf16Offset
- }
- currentContext = context
- }
-
- let range = appendRaw(segment)
- guard range.length > 0 else {
- return
- }
-
- if let style = context.blockStyle {
- styleRuns.append(.init(range: range, style: style))
- }
- appendInlineStyles(in: range, inlineIntent: inlineIntent, link: link)
- }
-
- mutating func finish() -> Presentation {
- closeCodeBlockIfNeeded()
- return .init(text: text, styleRuns: styleRuns, decorations: decorations)
- }
-
- private mutating func appendPrefix(for context: MarkdownContext) {
- let prefix = context.prefix
- guard prefix.isEmpty == false else {
- return
- }
- let range = appendRaw(prefix)
- if range.length > 0, let style = context.blockStyle {
- styleRuns.append(.init(range: range, style: style))
- }
- }
-
- private mutating func appendNewlines(_ count: Int) {
- guard count > 0 else {
- return
- }
- let existing = trailingNewlineCount()
- let needed = max(0, count - existing)
- guard needed > 0 else {
- return
- }
- _ = appendRaw(String(repeating: "\n", count: needed))
- }
-
- private mutating func appendRaw(_ string: String) -> NSRange {
- let length = ReviewMonitorLogStyler.utf16Length(string)
- let range = NSRange(location: utf16Offset, length: length)
- text += string
- utf16Offset += length
- return range
- }
-
- private mutating func appendInlineStyles(
- in range: NSRange,
- inlineIntent: InlinePresentationIntent?,
- link: URL?
- ) {
- if let inlineIntent {
- if inlineIntent.contains(.stronglyEmphasized) {
- styleRuns.append(.init(range: range, style: .strong))
- }
- if inlineIntent.contains(.emphasized) {
- styleRuns.append(.init(range: range, style: .emphasis))
- }
- if inlineIntent.contains(.code) {
- styleRuns.append(.init(range: range, style: .inlineCode))
- }
- if inlineIntent.contains(.strikethrough) {
- styleRuns.append(.init(range: range, style: .strikethrough))
- }
- }
- if link != nil {
- styleRuns.append(.init(range: range, style: .link))
- }
- }
-
- private mutating func closeCodeBlockIfNeeded() {
- guard let start = activeCodeBlockStart else {
- return
- }
- let range = NSRange(location: start, length: max(0, utf16Offset - start))
- if range.length > 0 {
- decorations.append(.init(blockID: blockID, range: range, style: .codeBlock))
- }
- activeCodeBlockStart = nil
- }
-
- private func trailingNewlineCount() -> Int {
- var count = 0
- for character in text.reversed() {
- guard character == "\n" else {
- return count
- }
- count += 1
- }
- return count
- }
- }
-
-}
-
-extension ReviewMonitorLog {
-struct Projection: Sendable {
- private struct GroupKey: Hashable, Sendable {
- var kind: ReviewLogEntry.Kind
- var groupID: String
- }
-
- private struct RenderedBlock: Sendable {
- var id: ReviewMonitorLog.BlockID
- var kind: ReviewLogEntry.Kind
- var groupID: String?
- var text: String
- var metadata: ReviewLogEntry.Metadata?
- }
-
- private struct EntrySignature: Equatable, Sendable {
- var id: UUID
- var kind: ReviewLogEntry.Kind
- var groupID: String?
- var replacesGroup: Bool
- var textUTF16Length: Int
- var textHash: Int
- var metadataHash: Int?
- var timestamp: Date
-
- init(_ entry: ReviewLogEntry) {
- var textHasher = Hasher()
- textHasher.combine(entry.text)
- if let metadata = entry.metadata {
- var metadataHasher = Hasher()
- metadataHasher.combine(metadata)
- metadataHash = metadataHasher.finalize()
- } else {
- metadataHash = nil
- }
- self.id = entry.id
- self.kind = entry.kind
- self.groupID = entry.groupID
- self.replacesGroup = entry.replacesGroup
- self.textUTF16Length = ReviewMonitorLog.Projection.utf16Length(entry.text)
- self.textHash = textHasher.finalize()
- self.timestamp = entry.timestamp
- }
- }
-
- private enum AppendResult: Sendable {
- case noVisibleChange
- case changed(ReviewMonitorLog.Change)
- case needsReload(replacementBlockID: ReviewMonitorLog.BlockID?)
- }
-
- private struct Accumulator: Sendable {
- private(set) var document = ReviewMonitorLog.Document()
- private(set) var hasVisibleSections = false
- private(set) var lastBlockIndex: Int?
-
- mutating func appendBlock(
- _ block: RenderedBlock,
- at blockIndex: Int
- ) -> ReviewMonitorLog.Append {
- let renderedText = ReviewMonitorLogStyler.renderedText(
- for: block.kind,
- source: block.text,
- blockID: block.id
- )
- let appended = appendedText(
- renderedText,
- after: document.text
- )
- let appendedSource = appendedText(
- block.text,
- after: document.sourceText
- )
- if hasVisibleSections == false {
- hasVisibleSections = true
- }
-
- let previousLength = document.textUTF16Length
- let previousSourceLength = document.sourceTextUTF16Length
- let suffixLength = ReviewMonitorLog.Projection.utf16Length(appended)
- let sourceSuffixLength = ReviewMonitorLog.Projection.utf16Length(appendedSource)
- let blockLength = ReviewMonitorLog.Projection.utf16Length(renderedText)
- let sourceBlockLength = ReviewMonitorLog.Projection.utf16Length(block.text)
- let blockRange = NSRange(
- location: previousLength + max(0, suffixLength - blockLength),
- length: blockLength
- )
- let sourceBlockRange = NSRange(
- location: previousSourceLength + max(0, sourceSuffixLength - sourceBlockLength),
- length: sourceBlockLength
- )
-
- document.text += appended
- document.textUTF16Length += suffixLength
- document.sourceText += appendedSource
- document.sourceTextUTF16Length += sourceSuffixLength
- let logBlock = ReviewMonitorLog.Block(
- id: block.id,
- kind: block.kind,
- groupID: block.groupID,
- range: blockRange,
- sourceRange: sourceBlockRange,
- metadata: block.metadata
- )
- document.blocks.append(logBlock)
- ReviewMonitorLogStyler.appendPresentation(for: logBlock, to: &document)
- lastBlockIndex = blockIndex
- return .init(
- kind: block.kind,
- blockID: block.id,
- range: blockRange,
- text: appended,
- textUTF16Length: suffixLength,
- animationSpans: ReviewMonitorLog.Append.animationSpans(
- forKind: block.kind,
- absoluteRange: blockRange,
- appendBaseLocation: previousLength
- )
- )
- }
-
- mutating func appendToCurrentBlock(
- _ block: RenderedBlock,
- at blockIndex: Int,
- sourceDelta: String,
- renderedDelta: String
- ) -> ReviewMonitorLog.Append? {
- guard renderedDelta.isEmpty == false,
- let blockIndexInDocument = document.blocks.lastIndex(where: { $0.id == block.id })
- else {
- return nil
- }
-
- let previousLength = document.textUTF16Length
- let deltaLength = ReviewMonitorLog.Projection.utf16Length(renderedDelta)
- let sourceDeltaLength = ReviewMonitorLog.Projection.utf16Length(sourceDelta)
- document.text += renderedDelta
- document.textUTF16Length += deltaLength
- document.sourceText += sourceDelta
- document.sourceTextUTF16Length += sourceDeltaLength
- document.blocks[blockIndexInDocument].range.length += deltaLength
- document.blocks[blockIndexInDocument].sourceRange.length += sourceDeltaLength
- document.blocks[blockIndexInDocument].metadata = block.metadata
- document.rebuildPresentation(forBlockAt: blockIndexInDocument)
- lastBlockIndex = blockIndex
- return .init(
- kind: block.kind,
- blockID: block.id,
- range: NSRange(location: previousLength, length: document.textUTF16Length - previousLength),
- text: renderedDelta,
- textUTF16Length: document.textUTF16Length - previousLength,
- animationSpans: ReviewMonitorLog.Append.animationSpans(
- forKind: block.kind,
- absoluteRange: NSRange(
- location: previousLength,
- length: document.textUTF16Length - previousLength
- ),
- appendBaseLocation: previousLength
- )
- )
- }
-
- mutating func replaceCurrentBlock(
- _ block: RenderedBlock,
- at blockIndex: Int
- ) -> ReviewMonitorLog.Replacement? {
- guard let blockIndexInDocument = document.blocks.lastIndex(where: { $0.id == block.id })
- else {
- return nil
- }
-
- let previousBlock = document.blocks[blockIndexInDocument]
- guard NSMaxRange(previousBlock.range) == document.textUTF16Length,
- NSMaxRange(previousBlock.sourceRange) == document.sourceTextUTF16Length
- else {
- return nil
- }
-
- let renderedText = ReviewMonitorLogStyler.renderedText(
- for: block.kind,
- source: block.text,
- blockID: block.id
- )
- let renderedLength = ReviewMonitorLog.Projection.utf16Length(renderedText)
- let sourceLength = ReviewMonitorLog.Projection.utf16Length(block.text)
- let textPrefix = (document.text as NSString).substring(
- with: NSRange(location: 0, length: previousBlock.range.location)
- )
- let sourcePrefix = (document.sourceText as NSString).substring(
- with: NSRange(location: 0, length: previousBlock.sourceRange.location)
- )
-
- document.text = textPrefix + renderedText
- document.textUTF16Length = previousBlock.range.location + renderedLength
- document.sourceText = sourcePrefix + block.text
- document.sourceTextUTF16Length = previousBlock.sourceRange.location + sourceLength
- document.blocks[blockIndexInDocument] = ReviewMonitorLog.Block(
- id: block.id,
- kind: block.kind,
- groupID: block.groupID,
- range: NSRange(location: previousBlock.range.location, length: renderedLength),
- sourceRange: NSRange(location: previousBlock.sourceRange.location, length: sourceLength),
- metadata: block.metadata
- )
- document.styleRuns.removeAll {
- $0.range.location >= previousBlock.range.location ||
- NSIntersectionRange($0.range, previousBlock.range).length > 0
- }
- document.decorations.removeAll {
- $0.blockID == block.id ||
- $0.range.location >= previousBlock.range.location ||
- NSIntersectionRange($0.range, previousBlock.range).length > 0
- }
- ReviewMonitorLogStyler.appendPresentation(for: document.blocks[blockIndexInDocument], to: &document)
- lastBlockIndex = blockIndex
- return .init(
- kind: block.kind,
- blockID: block.id,
- range: previousBlock.range,
- text: renderedText,
- textUTF16Length: renderedLength
- )
- }
-
- private func appendedText(_ blockText: String, after existingText: String) -> String {
- guard hasVisibleSections else {
- return blockText
- }
- if blockText.isEmpty {
- return "\n\n"
- }
- if existingText.hasSuffix("\n\n") {
- return blockText
- }
- if existingText.hasSuffix("\n") || blockText.hasPrefix("\n") {
- return "\n" + blockText
- }
- return "\n\n" + blockText
- }
- }
-
- private struct State: Sendable {
- var entries: [ReviewLogEntry]
- var entrySignatures: [EntrySignature]
- var blocks: [RenderedBlock]
- var indexByGroup: [GroupKey: Int]
- var projection: Accumulator
-
- init(entries: [ReviewLogEntry]) {
- self = Self.rebuild(entries: entries)
- }
-
- var document: ReviewMonitorLog.Document {
- projection.document
- }
-
- static func rebuild(entries: [ReviewLogEntry]) -> State {
- var state = State(
- entries: entries,
- entrySignatures: entries.map(EntrySignature.init),
- blocks: [],
- indexByGroup: [:],
- projection: .init()
- )
-
- for entry in entries {
- if let key = ReviewMonitorLog.Projection.mergeKey(for: entry) {
- if let index = state.indexByGroup[key] {
- if entry.replacesGroup {
- state.blocks[index].text = entry.text
- state.blocks[index].metadata = entry.metadata
- } else {
- state.blocks[index].text.append(entry.text)
- if let metadata = entry.metadata {
- state.blocks[index].metadata = metadata
- }
- }
- continue
- }
- state.indexByGroup[key] = state.blocks.count
- }
-
- state.blocks.append(.init(
- id: ReviewMonitorLog.Projection.blockID(for: entry),
- kind: entry.kind,
- groupID: entry.groupID,
- text: entry.text,
- metadata: entry.metadata
- ))
- }
-
- for (index, block) in state.blocks.enumerated() {
- _ = state.appendBlock(block, at: index)
- }
- return state
- }
-
- private init(
- entries: [ReviewLogEntry],
- entrySignatures: [EntrySignature],
- blocks: [RenderedBlock],
- indexByGroup: [GroupKey: Int],
- projection: Accumulator
- ) {
- self.entries = entries
- self.entrySignatures = entrySignatures
- self.blocks = blocks
- self.indexByGroup = indexByGroup
- self.projection = projection
- }
-
- mutating func append(_ entry: ReviewLogEntry) -> AppendResult {
- entries.append(entry)
- entrySignatures.append(.init(entry))
-
- if let key = ReviewMonitorLog.Projection.mergeKey(for: entry) {
- if let blockIndex = indexByGroup[key] {
- let oldText = blocks[blockIndex].text
- if entry.replacesGroup || blockIndex != blocks.indices.last {
- return .needsReload(
- replacementBlockID: entry.replacesGroup ? blocks[blockIndex].id : nil
- )
- }
-
- blocks[blockIndex].text.append(entry.text)
- if let metadata = entry.metadata {
- blocks[blockIndex].metadata = metadata
- }
- let newText = blocks[blockIndex].text
- let wasVisible = ReviewMonitorLog.Projection.isVisible(kind: entry.kind, text: oldText)
- let isVisible = ReviewMonitorLog.Projection.isVisible(kind: entry.kind, text: newText)
- if wasVisible,
- isVisible,
- ReviewMonitorLog.Projection.requiresBlockRerenderOnDelta(kind: entry.kind) {
- let blockID = blocks[blockIndex].id
- let oldRendered = ReviewMonitorLogStyler.renderedText(
- for: entry.kind,
- source: oldText,
- blockID: blockID
- )
- let newRendered = ReviewMonitorLogStyler.renderedText(
- for: entry.kind,
- source: newText,
- blockID: blockID
- )
- if let renderedDelta = ReviewMonitorLog.Projection.suffix(
- in: newRendered,
- afterPrefix: oldRendered
- ) {
- if let append = projection.appendToCurrentBlock(
- blocks[blockIndex],
- at: blockIndex,
- sourceDelta: entry.text,
- renderedDelta: renderedDelta
- ) {
- return .changed(.append(append))
- }
- if let replacement = projection.replaceCurrentBlock(
- blocks[blockIndex],
- at: blockIndex
- ) {
- return .changed(.replace(replacement))
- }
- return .noVisibleChange
- }
- if let replacement = projection.replaceCurrentBlock(
- blocks[blockIndex],
- at: blockIndex
- ) {
- return .changed(.replace(replacement))
- }
- return .needsReload(replacementBlockID: blockID)
- }
- if let append = appendTailGroupDelta(
- block: blocks[blockIndex],
- oldText: oldText,
- newText: newText,
- blockIndex: blockIndex,
- delta: entry.text
- ) {
- return .changed(.append(append))
- }
- return .noVisibleChange
- }
-
- indexByGroup[key] = blocks.count
- }
-
- let blockIndex = blocks.count
- let block = RenderedBlock(
- id: ReviewMonitorLog.Projection.blockID(for: entry),
- kind: entry.kind,
- groupID: entry.groupID,
- text: entry.text,
- metadata: entry.metadata
- )
- blocks.append(block)
- if let append = appendBlock(block, at: blockIndex) {
- return .changed(.append(append))
- }
- return .noVisibleChange
- }
-
- mutating func rebuildResolvingReplacement(
- previousDocument: ReviewMonitorLog.Document,
- replacementBlockID: ReviewMonitorLog.BlockID
- ) -> ReviewMonitorLog.Replacement? {
- let rebuilt = Self.rebuild(entries: entries)
- guard let replacement = Self.replacement(
- previous: previousDocument,
- current: rebuilt.document,
- blockID: replacementBlockID
- ) else {
- return nil
- }
- self = rebuilt
- return replacement
- }
-
- private mutating func appendBlock(
- _ block: RenderedBlock,
- at blockIndex: Int
- ) -> ReviewMonitorLog.Append? {
- guard ReviewMonitorLog.Projection.isVisible(
- kind: block.kind,
- text: block.text
- ) else {
- return nil
- }
- return projection.appendBlock(block, at: blockIndex)
- }
-
- private mutating func appendTailGroupDelta(
- block: RenderedBlock,
- oldText: String,
- newText: String,
- blockIndex: Int,
- delta: String
- ) -> ReviewMonitorLog.Append? {
- let wasVisible = ReviewMonitorLog.Projection.isVisible(
- kind: block.kind,
- text: oldText
- )
- let isVisible = ReviewMonitorLog.Projection.isVisible(
- kind: block.kind,
- text: newText
- )
-
- switch (wasVisible, isVisible) {
- case (false, false):
- return nil
- case (false, true):
- return projection.appendBlock(block, at: blockIndex)
- case (true, true):
- return projection.appendToCurrentBlock(
- block,
- at: blockIndex,
- sourceDelta: delta,
- renderedDelta: delta
- )
- case (true, false):
- return nil
- }
- }
-
- static func replacement(
- previous: ReviewMonitorLog.Document,
- current: ReviewMonitorLog.Document,
- blockID: ReviewMonitorLog.BlockID
- ) -> ReviewMonitorLog.Replacement? {
- guard let previousBlock = previous.blocks.first(where: { $0.id == blockID }),
- let currentBlock = current.blocks.first(where: { $0.id == blockID }),
- previousBlock.range.location == currentBlock.range.location,
- NSMaxRange(currentBlock.range) <= current.textUTF16Length
- else {
- return nil
- }
-
- let replacementText = (current.text as NSString).substring(with: currentBlock.range)
- return .init(
- kind: currentBlock.kind,
- blockID: currentBlock.id,
- range: previousBlock.range,
- text: replacementText,
- textUTF16Length: currentBlock.range.length
- )
- }
- }
-
- private var state = State(entries: [])
- private var document = ReviewMonitorLog.Document()
-
- var entryCount: Int {
- state.entrySignatures.count
- }
-
- var currentDocument: ReviewMonitorLog.Document {
- document
- }
-
- mutating func render(entries: [ReviewLogEntry]) -> ReviewMonitorLog.Document {
- let entrySignatures = entries.map(EntrySignature.init)
- guard entrySignatures != state.entrySignatures else {
- return document
- }
-
- let previousDocument = document
- let preferredChange: ReviewMonitorLog.Change?
- if entrySignatures.count == state.entrySignatures.count + 1,
- entrySignatures.dropLast().elementsEqual(state.entrySignatures),
- let entry = entries.last {
- switch state.append(entry) {
- case .changed(let change):
- preferredChange = change
- case .noVisibleChange:
- preferredChange = nil
- case .needsReload(let replacementBlockID):
- state = State.rebuild(entries: entries)
- if let replacementBlockID,
- let replacement = State.replacement(
- previous: previousDocument,
- current: state.document,
- blockID: replacementBlockID
- ) {
- preferredChange = .replace(replacement)
- } else {
- preferredChange = .reload
- }
- }
- } else {
- state = State.rebuild(entries: entries)
- preferredChange = .reload
- }
-
- if let resolved = Self.resolveDocument(
- previous: previousDocument,
- current: state.document,
- preferredChange: preferredChange
- ) {
- document = resolved
- }
- return document
- }
-
- mutating func append(
- entries: [ReviewLogEntry],
- sourceRange: Range
- ) -> ReviewMonitorLog.Document? {
- guard sourceRange.lowerBound <= state.entrySignatures.count else {
- return nil
- }
- guard state.entrySignatures.count < sourceRange.upperBound else {
- return document
- }
-
- let skipCount = state.entrySignatures.count - sourceRange.lowerBound
- guard skipCount >= 0,
- skipCount <= entries.count
- else {
- return nil
- }
-
- for entry in entries.dropFirst(skipCount) {
- let previousDocument = document
- let previousState = state
- switch state.append(entry) {
- case .changed(let preferredChange):
- if let resolved = Self.resolveDocument(
- previous: previousDocument,
- current: state.document,
- preferredChange: preferredChange
- ) {
- document = resolved
- } else {
- state = previousState
- return nil
- }
- case .noVisibleChange:
- continue
- case .needsReload(let replacementBlockID):
- guard let replacementBlockID,
- let replacement = state.rebuildResolvingReplacement(
- previousDocument: previousDocument,
- replacementBlockID: replacementBlockID
- ),
- let resolved = Self.resolveDocument(
- previous: previousDocument,
- current: state.document,
- preferredChange: .replace(replacement)
- )
- else {
- state = previousState
- return nil
- }
- document = resolved
- }
- }
- return document
- }
-
- private static func resolveDocument(
- previous: ReviewMonitorLog.Document,
- current: ReviewMonitorLog.Document,
- preferredChange: ReviewMonitorLog.Change?
- ) -> ReviewMonitorLog.Document? {
- guard let preferredChange else {
- return nil
- }
-
- guard contentChanged(previous: previous, current: current) else {
- return nil
- }
-
- var resolved = current
- resolved.revision = previous.revision &+ 1
-
- switch preferredChange {
- case .append(let append)
- where isContiguousAppend(
- append,
- previousUTF16Length: previous.textUTF16Length,
- currentUTF16Length: current.textUTF16Length
- ):
- resolved.lastChange = .append(append)
- case .replace(let replacement)
- where isValidReplacement(
- replacement,
- previousUTF16Length: previous.textUTF16Length,
- currentUTF16Length: current.textUTF16Length
- ):
- resolved.lastChange = .replace(replacement)
- default:
- resolved.lastChange = .reload
- }
- return resolved
- }
-
- private static func contentChanged(
- previous: ReviewMonitorLog.Document,
- current: ReviewMonitorLog.Document
- ) -> Bool {
- if previous.textUTF16Length != current.textUTF16Length {
- return true
- }
- if previous.sourceTextUTF16Length != current.sourceTextUTF16Length {
- return true
- }
- if previous.blocks != current.blocks {
- return true
- }
- if previous.styleRuns != current.styleRuns {
- return true
- }
- if previous.decorations != current.decorations {
- return true
- }
- return previous.text != current.text || previous.sourceText != current.sourceText
- }
-
- private static func isContiguousAppend(
- _ append: ReviewMonitorLog.Append,
- previousUTF16Length: Int,
- currentUTF16Length: Int
- ) -> Bool {
- let appendEnd = previousUTF16Length + append.textUTF16Length
- return append.textUTF16Length > 0 &&
- currentUTF16Length == appendEnd &&
- append.range.location >= previousUTF16Length &&
- NSMaxRange(append.range) <= appendEnd
- }
-
- private static func isValidReplacement(
- _ replacement: ReviewMonitorLog.Replacement,
- previousUTF16Length: Int,
- currentUTF16Length: Int
- ) -> Bool {
- let replacementEnd = replacement.range.location + replacement.textUTF16Length
- return replacement.textUTF16Length >= 0 &&
- NSMaxRange(replacement.range) <= previousUTF16Length &&
- currentUTF16Length == previousUTF16Length - replacement.range.length + replacement.textUTF16Length &&
- replacementEnd <= currentUTF16Length
- }
-
- private static func requiresBlockRerenderOnDelta(kind: ReviewLogEntry.Kind) -> Bool {
- switch kind {
- case .agentMessage, .plan, .todoList, .reasoning, .reasoningSummary, .rawReasoning:
- return true
- case .command, .commandOutput, .toolCall, .diagnostic, .error, .progress, .event, .contextCompaction:
- return false
- }
- }
-
- private static func suffix(in text: String, afterPrefix prefix: String) -> String? {
- guard text.hasPrefix(prefix) else {
- return nil
- }
- return String(text.dropFirst(prefix.count))
- }
-
- private static func blockID(for entry: ReviewLogEntry) -> ReviewMonitorLog.BlockID {
- if let key = mergeKey(for: entry) {
- return ReviewMonitorLog.BlockID("\(key.kind.rawValue):\(key.groupID)")
- }
- return ReviewMonitorLog.BlockID(entry.id.uuidString)
- }
-
- private static func mergeKey(for entry: ReviewLogEntry) -> GroupKey? {
- guard let groupID = entry.groupID,
- groupID.isEmpty == false
- else {
- return nil
- }
-
- switch entry.kind {
- case .agentMessage, .command, .commandOutput, .plan, .reasoning, .reasoningSummary, .rawReasoning, .contextCompaction:
- return GroupKey(kind: entry.kind, groupID: groupID)
- case .todoList, .toolCall, .diagnostic, .error, .progress, .event:
- return nil
- }
- }
-
- private static func isVisible(kind: ReviewLogEntry.Kind, text: String) -> Bool {
- guard displayedKinds.contains(kind) else {
- return false
- }
- if kind == .diagnostic {
- return true
- }
- return text.isEmpty == false
- }
-
- private static func utf16Length(_ text: String) -> Int {
- (text as NSString).length
- }
-
- private static let displayedKinds: Set = [
- .agentMessage,
- .command,
- .commandOutput,
- .plan,
- .todoList,
- .reasoning,
- .reasoningSummary,
- .rawReasoning,
- .toolCall,
- .diagnostic,
- .error,
- .progress,
- .event,
- .contextCompaction,
- ]
-}
-}
diff --git a/Sources/ReviewUI/Detail/ReviewMonitorLogRenderer.swift b/Sources/ReviewUI/Detail/ReviewMonitorLogRenderer.swift
deleted file mode 100644
index 69aa0861..00000000
--- a/Sources/ReviewUI/Detail/ReviewMonitorLogRenderer.swift
+++ /dev/null
@@ -1,94 +0,0 @@
-import CodexReview
-import ReviewMonitorRendering
-
-struct ReviewMonitorRenderedLogDocument: Equatable, Sendable {
- var source: ReviewMonitorLog.Document
- var display: ReviewMonitorLog.Document
-}
-
-actor ReviewMonitorLogRenderer {
- private var projection = ReviewMonitorLog.Projection()
- private var timelineProjection = ReviewMonitorTimelineLogProjection()
- private var displayDocument = ReviewMonitorLog.Document()
-
- func reset() {
- projection = ReviewMonitorLog.Projection()
- timelineProjection = ReviewMonitorTimelineLogProjection()
- displayDocument = ReviewMonitorLog.Document()
- }
-
- func render(entries: [ReviewLogEntry]) -> ReviewMonitorRenderedLogDocument {
- renderedDocument(from: projection.render(entries: entries))
- }
-
- func render(timelineDocument: ReviewTimelineDocument) -> ReviewMonitorRenderedLogDocument {
- renderedDocument(from: timelineProjection.render(timelineDocument: timelineDocument))
- }
-
- func append(
- entries: [ReviewLogEntry],
- sourceRange: Range
- ) -> ReviewMonitorRenderedLogDocument? {
- projection.append(entries: entries, sourceRange: sourceRange).map(renderedDocument(from:))
- }
-
- func appendSteps(
- entries: [ReviewLogEntry],
- sourceRange: Range
- ) -> [ReviewMonitorRenderedLogDocument]? {
- guard sourceRange.lowerBound <= projection.entryCount else {
- return nil
- }
- guard projection.entryCount < sourceRange.upperBound else {
- guard sourceRange.lowerBound < projection.entryCount else {
- return []
- }
- return [currentRenderedDocument()]
- }
-
- let skipCount = projection.entryCount - sourceRange.lowerBound
- guard skipCount >= 0,
- skipCount <= entries.count
- else {
- return nil
- }
-
- var documents: [ReviewMonitorRenderedLogDocument] = []
- if skipCount > 0 {
- documents.append(currentRenderedDocument())
- }
- var entryIndex = sourceRange.lowerBound + skipCount
- for entry in entries.dropFirst(skipCount) {
- guard let document = projection.append(
- entries: [entry],
- sourceRange: entryIndex..<(entryIndex + 1)
- ) else {
- return nil
- }
- documents.append(renderedDocument(from: document))
- entryIndex += 1
- }
- return documents
- }
-
- private func currentRenderedDocument() -> ReviewMonitorRenderedLogDocument {
- .init(
- source: projection.currentDocument,
- display: displayDocument
- )
- }
-
- private func renderedDocument(
- from source: ReviewMonitorLog.Document
- ) -> ReviewMonitorRenderedLogDocument {
- let display = ReviewMonitorCommandOutputDisplayDocument.make(
- from: source,
- previousDisplay: displayDocument
- )
- displayDocument = display
- return .init(
- source: source,
- display: display
- )
- }
-}
diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTimelineLogProjection.swift b/Sources/ReviewUI/Detail/ReviewMonitorTimelineLogProjection.swift
deleted file mode 100644
index 2fab4576..00000000
--- a/Sources/ReviewUI/Detail/ReviewMonitorTimelineLogProjection.swift
+++ /dev/null
@@ -1,641 +0,0 @@
-import Foundation
-import CodexReview
-import CodexReviewDomain
-import ReviewMonitorRendering
-
-struct ReviewMonitorTimelineLogProjection: Sendable {
- private struct ProjectedBlock: Equatable, Sendable {
- var id: ReviewMonitorLog.BlockID
- var kind: ReviewLogEntry.Kind
- var groupID: String?
- var text: String
- var metadata: ReviewLogEntry.Metadata?
- }
-
- private var document = ReviewMonitorLog.Document()
-
- var currentDocument: ReviewMonitorLog.Document {
- document
- }
-
- mutating func render(timelineDocument: ReviewTimelineDocument) -> ReviewMonitorLog.Document {
- let previous = document
- var current = Self.makeDocument(from: timelineDocument)
-
- guard Self.contentChanged(previous: previous, current: current) else {
- return document
- }
-
- current.revision = previous.revision &+ 1
- current.lastChange = Self.preferredChange(previous: previous, current: current)
- document = current
- return document
- }
-
- private static func makeDocument(from timelineDocument: ReviewTimelineDocument) -> ReviewMonitorLog.Document {
- var builder = DocumentBuilder()
- let blocksByID = Dictionary(uniqueKeysWithValues: timelineDocument.blocks.map { ($0.id, $0) })
- for blockID in timelineDocument.orderedBlockIDs {
- guard let block = blocksByID[blockID] else {
- continue
- }
- for projectedBlock in projectedBlocks(for: block) {
- builder.append(projectedBlock)
- }
- }
- return builder.document
- }
-
- private static func projectedBlocks(for block: ReviewTimelineDocument.Block) -> [ProjectedBlock] {
- switch block.content {
- case .approval(let approval):
- return [projectedBlock(
- block,
- kind: .event,
- text: [approval.title, approval.detail].compactMap { $0 }.joined(separator: "\n"),
- metadata: metadata(
- for: block,
- sourceType: block.kind.rawValue,
- title: approval.title,
- status: approval.status?.rawValue,
- detail: approval.detail
- )
- )]
- case .command(let command):
- let groupID = block.id.rawValue
- let metadata = commandMetadata(for: block, command: command)
- let commandLine = command.command.nilIfEmpty
- if (commandLine == nil || command.command == "Command"), command.output.isEmpty == false {
- return [
- ProjectedBlock(
- id: derivedBlockID(prefix: "commandOutput", from: block),
- kind: .commandOutput,
- groupID: groupID,
- text: command.output,
- metadata: outputOnlyCommandMetadata(for: block, command: command)
- ),
- ]
- }
- guard let commandLine else {
- return []
- }
- var blocks = [
- ProjectedBlock(
- id: derivedBlockID(prefix: "command", from: block),
- kind: .command,
- groupID: groupID,
- text: "$ \(commandLine)",
- metadata: metadata
- ),
- ]
- if command.output.isEmpty == false {
- blocks.append(ProjectedBlock(
- id: derivedBlockID(prefix: "commandOutput", from: block),
- kind: .commandOutput,
- groupID: groupID,
- text: command.output,
- metadata: metadata
- ))
- }
- return blocks
- case .contextCompaction(let contextCompaction):
- return [projectedBlock(
- block,
- kind: .contextCompaction,
- text: contextCompaction.title,
- metadata: metadata(
- for: block,
- sourceType: "contextCompaction",
- title: contextCompaction.title,
- status: contextCompaction.status?.rawValue
- )
- )]
- case .diagnostic(let diagnostic):
- return [projectedBlock(
- block,
- kind: legacyKind(for: block, fallback: .diagnostic),
- text: diagnostic.message,
- metadata: metadata(
- for: block,
- sourceType: block.kind.rawValue,
- title: diagnostic.message,
- status: block.phase.rawValue
- )
- )]
- case .fileChange(let fileChange):
- return [projectedBlock(
- block,
- kind: .commandOutput,
- groupID: block.id.rawValue,
- text: fileChange.output.isEmpty ? fileChange.title : fileChange.output,
- metadata: fileChangeMetadata(
- title: fileChange.title,
- status: fileChangeStatus(for: block, fileChange: fileChange),
- path: fileChange.paths.first
- )
- )]
- case .message(let message):
- return [projectedBlock(block, kind: .agentMessage, text: message.text)]
- case .plan(let plan):
- return [projectedBlock(block, kind: legacyKind(for: block, fallback: .plan), text: plan.markdown)]
- case .reasoning(let reasoning):
- return [projectedBlock(
- block,
- kind: legacyKind(
- for: block,
- fallback: reasoning.style == .raw ? .rawReasoning : .reasoningSummary
- ),
- text: reasoning.text
- )]
- case .search(let search):
- return [projectedBlock(
- block,
- kind: .toolCall,
- text: search.result ?? "Web search: \(search.query)",
- metadata: metadata(
- for: block,
- sourceType: "webSearch",
- title: "Web search",
- status: search.status?.rawValue,
- query: search.query,
- resultText: search.result
- )
- )]
- case .toolCall(let toolCall):
- let label = [toolCall.namespace, toolCall.server, toolCall.name]
- .compactMap { $0?.nilIfEmpty }
- .joined(separator: ".")
- return [projectedBlock(
- block,
- kind: .toolCall,
- text: toolCall.error?.nilIfEmpty
- ?? toolCall.result?.nilIfEmpty
- ?? toolCall.progress?.nilIfEmpty
- ?? label.nilIfEmpty
- ?? "Tool call",
- metadata: metadata(
- for: block,
- sourceType: block.kind.rawValue,
- title: label.nilIfEmpty,
- status: toolCall.status?.rawValue,
- detail: toolCall.arguments,
- namespace: toolCall.namespace,
- server: toolCall.server,
- tool: toolCall.name,
- resultText: toolCall.result,
- errorText: toolCall.error
- )
- )]
- case .unknown(let unknown):
- return [projectedBlock(
- block,
- kind: legacyKind(for: block, fallback: .event),
- text: [unknown.title, unknown.detail].compactMap { $0 }.joined(separator: "\n"),
- metadata: metadata(
- for: block,
- sourceType: unknown.rawKind?.rawValue ?? block.kind.rawValue,
- title: unknown.title,
- status: unknown.rawStatus ?? block.phase.rawValue,
- detail: unknown.detail
- )
- )]
- }
- }
-
- private static func projectedBlock(
- _ block: ReviewTimelineDocument.Block,
- kind: ReviewLogEntry.Kind,
- groupID: String? = nil,
- text: String,
- metadata: ReviewLogEntry.Metadata? = nil
- ) -> ProjectedBlock {
- ProjectedBlock(
- id: derivedBlockID(prefix: kind.rawValue, from: block),
- kind: kind,
- groupID: groupID,
- text: text,
- metadata: metadata
- )
- }
-
- private static func derivedBlockID(
- prefix: String,
- from block: ReviewTimelineDocument.Block
- ) -> ReviewMonitorLog.BlockID {
- ReviewMonitorLog.BlockID("\(prefix):\(block.id.rawValue)")
- }
-
- private static func legacyKind(
- for block: ReviewTimelineDocument.Block,
- fallback: ReviewLogEntry.Kind
- ) -> ReviewLogEntry.Kind {
- ReviewLogEntry.Kind(rawValue: block.kind.rawValue) ?? fallback
- }
-
- private static func commandMetadata(
- for block: ReviewTimelineDocument.Block,
- command: ReviewTimelineDocument.Command
- ) -> ReviewLogEntry.Metadata {
- let status = commandStatus(for: block, command: command)
- return .init(
- sourceType: "commandExecution",
- status: status,
- itemID: block.sourceItemID.rawValue,
- command: command.command,
- cwd: command.cwd,
- exitCode: command.exitCode,
- startedAt: block.startedAt,
- completedAt: block.completedAt,
- durationMs: command.durationMs ?? block.durationMs,
- commandActions: command.actions.map(commandAction),
- commandStatus: status
- )
- }
-
- private static func commandStatus(
- for block: ReviewTimelineDocument.Block,
- command: ReviewTimelineDocument.Command
- ) -> String {
- if block.phase.isTerminal {
- return block.phase.rawValue
- }
- if let status = command.status?.rawValue,
- ReviewItemPhase.normalized(status).isTerminal {
- return status
- }
- if let exitCode = command.exitCode {
- return exitCode == 0
- ? ReviewCommandStatus.completed.rawValue
- : ReviewCommandStatus.failed.rawValue
- }
- if block.isActive {
- if let status = command.status?.rawValue {
- return status
- }
- return block.phase.rawValue
- }
- return ReviewCommandStatus.completed.rawValue
- }
-
- private static func outputOnlyCommandMetadata(
- for block: ReviewTimelineDocument.Block,
- command: ReviewTimelineDocument.Command
- ) -> ReviewLogEntry.Metadata {
- guard hasStructuredOutputOnlyCommandMetadata(for: block, command: command) else {
- return genericCommandOutputMetadata(for: block)
- }
-
- let normalizedStatus = commandStatus(for: block, command: command)
- return ReviewLogEntry.Metadata(
- sourceType: "commandExecution",
- status: normalizedStatus,
- itemID: block.sourceItemID.rawValue,
- cwd: command.cwd,
- exitCode: command.exitCode,
- startedAt: block.startedAt,
- completedAt: block.completedAt,
- durationMs: command.durationMs ?? block.durationMs,
- commandActions: command.actions.map(commandAction),
- commandStatus: normalizedStatus
- )
- }
-
- private static func hasStructuredOutputOnlyCommandMetadata(
- for block: ReviewTimelineDocument.Block,
- command: ReviewTimelineDocument.Command
- ) -> Bool {
- command.cwd != nil ||
- command.exitCode != nil ||
- command.status != nil ||
- command.source != nil ||
- command.processID != nil ||
- command.actions.isEmpty == false ||
- command.durationMs != nil ||
- block.startedAt != nil ||
- block.completedAt != nil ||
- block.durationMs != nil
- }
-
- private static func genericCommandOutputMetadata(
- for block: ReviewTimelineDocument.Block
- ) -> ReviewLogEntry.Metadata {
- .init(
- sourceType: "command",
- title: "Command output",
- status: block.phase.isTerminal || block.isActive
- ? block.phase.rawValue
- : "completed"
- )
- }
-
- private static func fileChangeMetadata(
- title: String?,
- status: String?,
- path: String?
- ) -> ReviewLogEntry.Metadata {
- .init(
- sourceType: "fileChange",
- title: title,
- status: status,
- path: path
- )
- }
-
- private static func fileChangeStatus(
- for block: ReviewTimelineDocument.Block,
- fileChange: ReviewTimelineDocument.FileChange
- ) -> String {
- if block.phase.isTerminal {
- return block.phase.rawValue
- }
- return fileChange.status?.rawValue ?? block.phase.rawValue
- }
-
- private static func commandAction(
- _ action: ReviewTimelineDocument.Command.Action
- ) -> ReviewLogEntry.Metadata.CommandAction {
- .init(
- kind: commandActionKind(action.kind),
- command: action.command,
- name: action.name,
- path: action.path,
- query: action.query
- )
- }
-
- private static func commandActionKind(
- _ kind: ReviewCommandActionKind
- ) -> ReviewLogEntry.Metadata.CommandAction.Kind {
- switch kind.rawValue {
- case ReviewCommandActionKind.read.rawValue:
- return .read
- case ReviewCommandActionKind.listFiles.rawValue:
- return .listFiles
- case ReviewCommandActionKind.search.rawValue:
- return .search
- default:
- return .unknown
- }
- }
-
- private static func metadata(
- for block: ReviewTimelineDocument.Block,
- sourceType: String,
- title: String? = nil,
- status: String? = nil,
- detail: String? = nil,
- query: String? = nil,
- path: String? = nil,
- namespace: String? = nil,
- server: String? = nil,
- tool: String? = nil,
- resultText: String? = nil,
- errorText: String? = nil
- ) -> ReviewLogEntry.Metadata {
- .init(
- sourceType: sourceType,
- title: title,
- status: status ?? block.phase.rawValue,
- detail: detail,
- itemID: block.sourceItemID.rawValue,
- startedAt: block.startedAt,
- completedAt: block.completedAt,
- durationMs: block.durationMs,
- namespace: namespace,
- server: server,
- tool: tool,
- query: query,
- path: path,
- resultText: resultText,
- errorText: errorText
- )
- }
-
- private static func preferredChange(
- previous: ReviewMonitorLog.Document,
- current: ReviewMonitorLog.Document
- ) -> ReviewMonitorLog.Change {
- if let append = appendChange(previous: previous, current: current) {
- return .append(append)
- }
- if let replacement = replacementChange(previous: previous, current: current) {
- return .replace(replacement)
- }
- return .reload
- }
-
- private static func appendChange(
- previous: ReviewMonitorLog.Document,
- current: ReviewMonitorLog.Document
- ) -> ReviewMonitorLog.Append? {
- guard current.textUTF16Length > previous.textUTF16Length,
- current.text.hasPrefix(previous.text)
- else {
- return nil
- }
-
- let suffix = String(current.text.dropFirst(previous.text.count))
- let suffixLength = utf16Length(suffix)
- let suffixRange = NSRange(location: previous.textUTF16Length, length: suffixLength)
- let block = current.blocks.first {
- NSIntersectionRange($0.range, suffixRange).length > 0
- }
- guard existingPresentationUnchanged(
- previous: previous,
- current: current,
- suffixBlockID: block?.id
- ) else {
- return nil
- }
- return .init(
- kind: block?.kind ?? .event,
- blockID: block?.id ?? ReviewMonitorLog.BlockID("timelineAppend"),
- range: suffixRange,
- text: suffix,
- textUTF16Length: suffixLength,
- animationSpans: current.blocks.flatMap { block in
- let intersection = NSIntersectionRange(block.range, suffixRange)
- guard intersection.length > 0 else {
- return [] as [ReviewMonitorLog.AnimationSpan]
- }
- return ReviewMonitorLog.Append.animationSpans(
- forKind: block.kind,
- absoluteRange: intersection,
- appendBaseLocation: previous.textUTF16Length
- )
- }
- )
- }
-
- private static func existingPresentationUnchanged(
- previous: ReviewMonitorLog.Document,
- current: ReviewMonitorLog.Document,
- suffixBlockID: ReviewMonitorLog.BlockID?
- ) -> Bool {
- var currentBlocksByID = [ReviewMonitorLog.BlockID: ReviewMonitorLog.Block]()
- for currentBlock in current.blocks {
- currentBlocksByID[currentBlock.id] = currentBlock
- }
-
- for previousBlock in previous.blocks {
- guard let currentBlock = currentBlocksByID[previousBlock.id] else {
- return false
- }
- if previousBlock.id == suffixBlockID {
- guard currentBlock.kind == previousBlock.kind,
- currentBlock.groupID == previousBlock.groupID,
- currentBlock.range.location == previousBlock.range.location,
- currentBlock.sourceRange.location == previousBlock.sourceRange.location,
- currentBlock.metadata == previousBlock.metadata,
- currentBlock.range.length >= previousBlock.range.length,
- currentBlock.sourceRange.length >= previousBlock.sourceRange.length
- else {
- return false
- }
- } else if currentBlock != previousBlock {
- return false
- }
- }
- return true
- }
-
- private static func replacementChange(
- previous: ReviewMonitorLog.Document,
- current: ReviewMonitorLog.Document
- ) -> ReviewMonitorLog.Replacement? {
- for previousBlock in previous.blocks {
- guard let currentBlock = current.blocks.first(where: { $0.id == previousBlock.id }),
- currentBlock.range.location == previousBlock.range.location,
- NSMaxRange(currentBlock.range) <= current.textUTF16Length
- else {
- continue
- }
-
- let replacementText = (current.text as NSString).substring(with: currentBlock.range)
- let candidate = replacingText(
- in: previous.text,
- range: previousBlock.range,
- with: replacementText
- )
- guard candidate == current.text else {
- continue
- }
- return .init(
- kind: currentBlock.kind,
- blockID: currentBlock.id,
- range: previousBlock.range,
- text: replacementText,
- textUTF16Length: currentBlock.range.length
- )
- }
- return nil
- }
-
- private static func replacingText(
- in text: String,
- range: NSRange,
- with replacement: String
- ) -> String {
- let string = text as NSString
- let prefix = string.substring(with: NSRange(location: 0, length: range.location))
- let suffixLocation = NSMaxRange(range)
- let suffix = string.substring(
- with: NSRange(location: suffixLocation, length: string.length - suffixLocation)
- )
- return prefix + replacement + suffix
- }
-
- private static func contentChanged(
- previous: ReviewMonitorLog.Document,
- current: ReviewMonitorLog.Document
- ) -> Bool {
- previous.text != current.text ||
- previous.sourceText != current.sourceText ||
- previous.blocks != current.blocks ||
- previous.styleRuns != current.styleRuns ||
- previous.decorations != current.decorations
- }
-
- private static func utf16Length(_ text: String) -> Int {
- (text as NSString).length
- }
-
- private struct DocumentBuilder {
- private(set) var document = ReviewMonitorLog.Document()
- private var hasVisibleSections = false
-
- mutating func append(_ block: ProjectedBlock) {
- guard Self.isVisible(kind: block.kind, text: block.text) else {
- return
- }
-
- let renderedText = ReviewMonitorLogStyler.renderedText(
- for: block.kind,
- source: block.text,
- blockID: block.id
- )
- let appended = appendedText(renderedText, after: document.text)
- let appendedSource = appendedText(block.text, after: document.sourceText)
- hasVisibleSections = true
-
- let previousLength = document.textUTF16Length
- let previousSourceLength = document.sourceTextUTF16Length
- let suffixLength = ReviewMonitorTimelineLogProjection.utf16Length(appended)
- let sourceSuffixLength = ReviewMonitorTimelineLogProjection.utf16Length(appendedSource)
- let blockLength = ReviewMonitorTimelineLogProjection.utf16Length(renderedText)
- let sourceBlockLength = ReviewMonitorTimelineLogProjection.utf16Length(block.text)
- let blockRange = NSRange(
- location: previousLength + max(0, suffixLength - blockLength),
- length: blockLength
- )
- let sourceBlockRange = NSRange(
- location: previousSourceLength + max(0, sourceSuffixLength - sourceBlockLength),
- length: sourceBlockLength
- )
-
- document.text += appended
- document.textUTF16Length += suffixLength
- document.sourceText += appendedSource
- document.sourceTextUTF16Length += sourceSuffixLength
- let logBlock = ReviewMonitorLog.Block(
- id: block.id,
- kind: block.kind,
- groupID: block.groupID,
- range: blockRange,
- sourceRange: sourceBlockRange,
- metadata: block.metadata
- )
- document.blocks.append(logBlock)
- ReviewMonitorLogStyler.appendPresentation(for: logBlock, to: &document)
- }
-
- private func appendedText(_ blockText: String, after existingText: String) -> String {
- guard hasVisibleSections else {
- return blockText
- }
- if blockText.isEmpty {
- return "\n\n"
- }
- if existingText.hasSuffix("\n\n") {
- return blockText
- }
- if existingText.hasSuffix("\n") || blockText.hasPrefix("\n") {
- return "\n" + blockText
- }
- return "\n\n" + blockText
- }
-
- private static func isVisible(kind: ReviewLogEntry.Kind, text: String) -> Bool {
- if kind == .diagnostic {
- return true
- }
- return text.isEmpty == false
- }
- }
-}
-
-private extension String {
- var nilIfEmpty: String? {
- isEmpty ? nil : self
- }
-}
diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift
index 91d8f3ce..1513d603 100644
--- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift
+++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift
@@ -1,37 +1,35 @@
import AppKit
+import CodexKit
import ObservationBridge
-import CodexReview
-import CodexReviewDomain
-import ReviewMonitorRendering
+import ReviewChatLogUI
@MainActor
final class ReviewMonitorTransportViewController: NSViewController {
- private enum DisplayedSelection: Equatable {
- case job(String)
- case workspaceSection(String)
- }
-
+ private let codexModelSource: ReviewMonitorCodexModelSource?
private let uiState: ReviewMonitorUIState
- private let store: CodexReviewStore
- private let logScrollView = ReviewMonitorLogScrollView()
- private var logRenderer = ReviewMonitorLogRenderer()
- private let workspaceFindingsView = ReviewMonitorWorkspaceFindingsView()
+ private let chatLogTarget = ReviewMonitorCodexChatLogTarget()
private let placeholderViewController = PlaceholderViewController()
private var displayedContentConstraints: [NSLayoutConstraint] = []
private var selectionObservation: PortableObservationTracking.Token?
- private var selectedJobObservation: PortableObservationTracking.Token?
- private var selectedWorkspaceFindingsObservation: PortableObservationTracking.Token?
- private var boundJob: CodexReviewJob?
- private var boundWorkspaceSection: ReviewMonitorWorkspaceSectionSelection?
- private var displayedSelection: DisplayedSelection?
- private var logScrollTargetsByJobID: [String: ReviewMonitorLogScrollView.ScrollRestorationTarget] = [:]
- private var logRenderTask: Task?
- private var logRenderGeneration: UInt64 = 0
- private var appliedLogRenderGeneration: UInt64 = 0
- private var hasAppliedBoundJobLog = false
-
- init(store: CodexReviewStore, uiState: ReviewMonitorUIState) {
- self.store = store
+ private var boundModelContext: CodexModelContext?
+ private var boundChatID: CodexThreadID?
+ private var displayedSelection: ReviewMonitorSelectionID?
+
+ convenience init(
+ uiState: ReviewMonitorUIState,
+ modelContext: CodexModelContext
+ ) {
+ self.init(
+ uiState: uiState,
+ codexModelSource: ReviewMonitorCodexModelSource(modelContext: modelContext)
+ )
+ }
+
+ init(
+ uiState: ReviewMonitorUIState,
+ codexModelSource: ReviewMonitorCodexModelSource? = nil
+ ) {
+ self.codexModelSource = codexModelSource
self.uiState = uiState
super.init(nibName: nil, bundle: nil)
}
@@ -43,9 +41,6 @@ final class ReviewMonitorTransportViewController: NSViewController {
isolated deinit {
selectionObservation?.cancel()
- selectedJobObservation?.cancel()
- selectedWorkspaceFindingsObservation?.cancel()
- logRenderTask?.cancel()
}
override func loadView() {
@@ -68,33 +63,28 @@ final class ReviewMonitorTransportViewController: NSViewController {
private func configureHierarchy() {
let safeArea = view.safeAreaLayoutGuide
let placeholderView = placeholderViewController.view
+ let chatLogView = chatLogTarget.view
addChild(placeholderViewController)
placeholderView.translatesAutoresizingMaskIntoConstraints = false
- view.addSubview(logScrollView)
- view.addSubview(workspaceFindingsView)
+ view.addSubview(chatLogView)
view.addSubview(placeholderView)
displayedContentConstraints = [
- logScrollView.topAnchor.constraint(equalTo: view.topAnchor),
- logScrollView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor),
- logScrollView.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor),
- logScrollView.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor),
+ chatLogView.topAnchor.constraint(equalTo: view.topAnchor),
+ chatLogView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor),
+ chatLogView.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor),
+ chatLogView.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor),
]
NSLayoutConstraint.activate(
displayedContentConstraints
- + [
- workspaceFindingsView.topAnchor.constraint(equalTo: view.topAnchor),
- workspaceFindingsView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor),
- workspaceFindingsView.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor),
- workspaceFindingsView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
-
- placeholderView.topAnchor.constraint(equalTo: view.topAnchor),
- placeholderView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor),
- placeholderView.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor),
- placeholderView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
- ]
+ + [
+ placeholderView.topAnchor.constraint(equalTo: view.topAnchor),
+ placeholderView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor),
+ placeholderView.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor),
+ placeholderView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ ]
)
}
@@ -102,145 +92,78 @@ final class ReviewMonitorTransportViewController: NSViewController {
selectionObservation?.cancel()
selectionObservation = withPortableContinuousObservation { [weak self, uiState] event in
let selection = uiState.selection
+ let modelContext = self?.codexModelSource?.modelContext
guard let self else {
return
}
- guard event.kind == .initial || self.selectionRequiresPresentationUpdate(selection) else {
+ guard event.kind == .initial
+ || self.selectionRequiresPresentationUpdate(selection, modelContext: modelContext)
+ else {
return
}
- self.updatePresentation(selection: selection)
+ self.updatePresentation(selection: selection, modelContext: modelContext)
}
}
- private func selectionRequiresPresentationUpdate(_ selection: ReviewMonitorSelection?) -> Bool {
+ private func selectionRequiresPresentationUpdate(
+ _ selection: ReviewMonitorSelection?,
+ modelContext: CodexModelContext?
+ ) -> Bool {
switch selection {
- case .job(let selectedJob):
- return boundJob !== selectedJob || displayedSelection != .job(selectedJob.id)
- case .workspaceSection(let selectedSection):
- return boundWorkspaceSection != selectedSection || displayedSelection != .workspaceSection(selectedSection.id)
+ case .workspaceGroup:
+ return displayedSelection != selection?.id
+ case .chat(let selectedChatID):
+ return boundChatID != selectedChatID
+ || boundModelContext !== modelContext
+ || displayedSelection != selection?.id
case nil:
return displayedSelection != nil
}
}
- private func updatePresentation(selection: ReviewMonitorSelection?) {
+ private func updatePresentation(selection: ReviewMonitorSelection?, modelContext: CodexModelContext?) {
switch selection {
- case .job(let selectedJob):
- clearDisplayedWorkspace()
- displayJob(selectedJob)
+ case .workspaceGroup:
+ clearDisplayedLogSelection()
+ displayPlaceholder(.noFindings)
+ chatLogTarget.view.isHidden = true
+ displayedSelection = selection?.id
+
+ case .chat:
+ if case .chat(let selectedChatID) = selection {
+ displayChat(selectedChatID, modelContext: modelContext)
+ }
hidePlaceholder()
- logScrollView.isHidden = false
- workspaceFindingsView.isHidden = true
- displayedSelection = .job(selectedJob.id)
-
- case .workspaceSection(let selectedSection):
- clearDisplayedJob()
- displayWorkspaceSection(selectedSection)
- logScrollView.isHidden = true
- displayedSelection = .workspaceSection(selectedSection.id)
+ chatLogTarget.view.isHidden = false
+ displayedSelection = selection?.id
case nil:
- clearDisplayedJob()
- clearDisplayedWorkspace()
+ clearDisplayedLogSelection()
displayPlaceholder(.noSelection)
- logScrollView.isHidden = true
- workspaceFindingsView.isHidden = true
+ chatLogTarget.view.isHidden = true
displayedSelection = nil
}
}
- private func displayJob(_ selectedJob: CodexReviewJob) {
- let isSwitchingRenderedJob = boundJob != nil && boundJob !== selectedJob
- cacheBoundJobScrollTarget()
- if isSwitchingRenderedJob {
- logScrollView.resetFindStateForContentReuse()
- }
- selectedJobObservation?.cancel()
- selectedJobObservation = nil
- resetLogRenderer()
- boundJob = selectedJob
-
- selectedJobObservation = withPortableContinuousObservation { [weak self] event in
- let timeline = selectedJob.timeline
- _ = timeline.revision
- let eventKind = event.kind
- let shouldRender = eventKind == .initial || event.matches(\ReviewTimeline.revision)
- guard let self,
- self.boundJob === selectedJob
- else {
- return
- }
- guard shouldRender else {
- return
- }
- self.renderBoundJobLog(
- timeline: timeline,
- restorationTarget: eventKind == .initial
- ? self.restorationTarget(selectedJob)
- : self.logScrollView.currentScrollRestorationTarget,
- allowIncrementalUpdate: eventKind != .initial
- )
- }
- }
-
- private func clearDisplayedJob() {
- cacheBoundJobScrollTarget()
- selectedJobObservation?.cancel()
- selectedJobObservation = nil
- boundJob = nil
- resetLogRenderer()
- logScrollView.resetFindStateForContentReuse()
- logScrollView.clear()
- }
-
- private func displayWorkspaceSection(_ section: ReviewMonitorWorkspaceSectionSelection) {
- if boundWorkspaceSection != section {
- selectedWorkspaceFindingsObservation?.cancel()
- selectedWorkspaceFindingsObservation = nil
- boundWorkspaceSection = section
- bindWorkspaceSectionObservation(section)
+ private func displayChat(_ selectedChatID: CodexThreadID, modelContext: CodexModelContext?) {
+ guard boundChatID != selectedChatID || boundModelContext !== modelContext else {
+ return
}
- }
-
- private func clearDisplayedWorkspace() {
- selectedWorkspaceFindingsObservation?.cancel()
- selectedWorkspaceFindingsObservation = nil
- boundWorkspaceSection = nil
- workspaceFindingsView.clear()
- workspaceFindingsView.isHidden = true
- }
-
- private func bindWorkspaceSectionObservation(_ section: ReviewMonitorWorkspaceSectionSelection) {
- selectedWorkspaceFindingsObservation = withPortableContinuousObservation { [weak self] _ in
- guard let self,
- self.boundWorkspaceSection?.id == section.id
- else {
- return
- }
- let entries = self.workspaceFindingEntries(for: self.currentWorkspaces(for: section))
- self.renderWorkspaceFindings(entries: entries)
+ boundChatID = selectedChatID
+ boundModelContext = modelContext
+ guard let modelContext else {
+ chatLogTarget.clear()
+ return
}
- }
- @discardableResult
- private func renderWorkspaceFindings(entries: [ReviewMonitorWorkspaceFindingsView.Entry]) -> Bool {
- let rendered = workspaceFindingsView.render(entries: entries)
- let presentationChanged = updateWorkspaceFindingsPresentation(hasFindings: entries.isEmpty == false)
- return rendered || presentationChanged
+ let chat = modelContext.model(for: selectedChatID)
+ chatLogTarget.bind(chat: chat, modelContext: modelContext)
}
- @discardableResult
- private func updateWorkspaceFindingsPresentation(hasFindings: Bool) -> Bool {
- if hasFindings {
- let placeholderChanged = hidePlaceholder()
- let findingsChanged = workspaceFindingsView.isHidden
- workspaceFindingsView.isHidden = false
- return placeholderChanged || findingsChanged
- }
-
- let findingsChanged = workspaceFindingsView.isHidden == false
- workspaceFindingsView.isHidden = true
- return displayPlaceholder(.noFindings) || findingsChanged
+ private func clearDisplayedLogSelection() {
+ boundChatID = nil
+ boundModelContext = nil
+ chatLogTarget.clear()
}
@discardableResult
@@ -258,186 +181,21 @@ final class ReviewMonitorTransportViewController: NSViewController {
return hiddenChanged
}
- private func workspaceFindingEntries(
- for workspaces: [CodexReviewWorkspace]
- ) -> [ReviewMonitorWorkspaceFindingsView.Entry] {
- workspaces.flatMap { workspace in
- workspaceFindingEntries(in: workspace)
- }
- }
-
- private func workspaceFindingEntries(
- in workspace: CodexReviewWorkspace
- ) -> [ReviewMonitorWorkspaceFindingsView.Entry] {
- store.orderedJobs(in: workspace).flatMap { job -> [ReviewMonitorWorkspaceFindingsView.Entry] in
- guard let result = job.core.output.reviewResult,
- result.state == .hasFindings
- else {
- return []
- }
- let threadID = workspaceFindingThreadID(for: job)
- return result.findings.map { finding in
- ReviewMonitorWorkspaceFindingsView.Entry(
- threadID: threadID,
- targetSummary: job.targetSummary,
- priority: finding.priority,
- title: finding.title,
- body: finding.body,
- locationText: locationText(for: finding.location, in: workspace)
- )
- }
- }
- }
-
- private func currentWorkspaces(
- for section: ReviewMonitorWorkspaceSectionSelection
- ) -> [CodexReviewWorkspace] {
- let workspacesByCWD = Dictionary(
- uniqueKeysWithValues: store.orderedWorkspaces.map { ($0.cwd, $0) }
- )
- return section.workspaceCWDs.compactMap { workspacesByCWD[$0] }
- }
-
- private func locationText(
- for location: ParsedReviewResult.Finding.Location?,
- in workspace: CodexReviewWorkspace
- ) -> String? {
- guard let location else {
- return nil
- }
-
- let path: String
- if let relativePath = workspaceRelativePath(location.path, in: workspace) {
- path = relativePath
- } else {
- path = location.path
- }
- return "\(path):\(location.startLine)-\(location.endLine)"
- }
-
- private func workspaceRelativePath(
- _ path: String,
- in workspace: CodexReviewWorkspace
- ) -> String? {
- guard path.hasPrefix("/"), workspace.cwd.hasPrefix("/") else {
- return nil
- }
- let workspaceURL = standardizedFileURL(workspace.cwd, isDirectory: true)
- let fileURL = standardizedFileURL(path, isDirectory: false)
- let workspaceComponents = workspaceURL.pathComponents
- let fileComponents = fileURL.pathComponents
- guard fileComponents.count > workspaceComponents.count,
- fileComponents.starts(with: workspaceComponents)
- else {
- return nil
- }
- return fileComponents
- .dropFirst(workspaceComponents.count)
- .joined(separator: "/")
- }
-
- private func standardizedFileURL(_ path: String, isDirectory: Bool) -> URL {
- URL(fileURLWithPath: path, isDirectory: isDirectory)
- .standardizedFileURL
- .resolvingSymlinksInPath()
- }
-
- private func workspaceFindingThreadID(for job: CodexReviewJob) -> String {
- if let reviewThreadID = nonEmptyID(job.core.run.reviewThreadID) {
- return reviewThreadID
- }
- if let threadID = nonEmptyID(job.core.run.threadID) {
- return threadID
- }
- return job.id
- }
-
- private func nonEmptyID(_ id: String?) -> String? {
- guard let id else {
- return nil
- }
- let trimmed = id.trimmingCharacters(in: .whitespacesAndNewlines)
- return trimmed.isEmpty ? nil : trimmed
- }
-
- @discardableResult
- private func renderBoundJobLog(
- timeline: ReviewTimeline,
- restorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget,
- allowIncrementalUpdate: Bool
- ) -> Bool {
- guard let boundJob else {
- return false
- }
-
- logRenderGeneration &+= 1
- let generation = logRenderGeneration
- let renderer = logRenderer
- let jobID = boundJob.id
- logRenderTask?.cancel()
- logRenderTask = Task { @MainActor [weak self] in
- let timelineDocument = ReviewTimelineDocumentRenderer().document(from: timeline)
- let renderedDocument = await renderer.render(timelineDocument: timelineDocument)
- guard Task.isCancelled == false,
- let self,
- self.logRenderGeneration == generation,
- self.boundJob?.id == jobID
- else {
- return
- }
- _ = self.logScrollView.render(
- sourceDocument: renderedDocument.source,
- displayDocument: renderedDocument.display,
- restoring: restorationTarget,
- allowIncrementalUpdate: allowIncrementalUpdate && self.hasAppliedBoundJobLog
- )
- self.appliedLogRenderGeneration = generation
- self.hasAppliedBoundJobLog = true
- }
- return true
- }
-
- private func cacheBoundJobScrollTarget() {
- guard let boundJob else {
- return
- }
- logScrollTargetsByJobID[boundJob.id] = logScrollView.currentScrollRestorationTarget
- }
-
- private func resetLogRenderer() {
- logRenderTask?.cancel()
- logRenderTask = nil
- logRenderGeneration &+= 1
- appliedLogRenderGeneration = logRenderGeneration
- hasAppliedBoundJobLog = false
- logRenderer = ReviewMonitorLogRenderer()
- }
-
- private func restorationTarget(
- _ job: CodexReviewJob
- ) -> ReviewMonitorLogScrollView.ScrollRestorationTarget {
- return logScrollTargetsByJobID[job.id] ?? .bottom
- }
-
@discardableResult
func performDisplayedTextFinderAction(_ sender: Any?) -> Bool {
switch displayedSelection {
- case .job:
- return logScrollView.performDisplayedTextFinderAction(sender)
- case .workspaceSection:
- return workspaceFindingsView.performDisplayedTextFinderAction(sender)
- case nil:
+ case .chat:
+ return chatLogTarget.performDisplayedTextFinderAction(sender)
+ case .workspaceGroup, nil:
return false
}
}
func validateDisplayedTextFinderAction(_ item: NSValidatedUserInterfaceItem) -> Bool {
switch displayedSelection {
- case .job:
- return logScrollView.validateDisplayedTextFinderAction(item)
- case .workspaceSection:
- return workspaceFindingsView.validateDisplayedTextFinderAction(item)
- case nil:
+ case .chat:
+ return chatLogTarget.validateDisplayedTextFinderAction(item)
+ case .workspaceGroup, nil:
return false
}
}
@@ -445,722 +203,565 @@ final class ReviewMonitorTransportViewController: NSViewController {
}
#if DEBUG
-@MainActor
-extension ReviewMonitorTransportViewController {
- struct RenderSnapshotForTesting: Sendable, Equatable {
- let title: String?
- let summary: String?
- let log: String
- let isShowingEmptyState: Bool
- }
-
- struct WorkspaceFindingSnapshotForTesting: Sendable, Equatable {
- let text: String
- let isShowingNoFindingsState: Bool
- let isShowingFindingsList: Bool
- }
-
- enum DisplayedSelectionForTesting: Sendable, Equatable {
- case job(String)
- case workspaceSection(String)
- }
-
- struct RenderedStateForTesting: Sendable, Equatable {
- let snapshot: RenderSnapshotForTesting
- let selection: DisplayedSelectionForTesting?
- }
-
- var selectionObservationForTesting: PortableObservationTracking.Token? {
- selectionObservation
- }
-
- var selectedJobObservationForTesting: PortableObservationTracking.Token? {
- selectedJobObservation
- }
-
- var selectedWorkspaceFindingsObservationForTesting: PortableObservationTracking.Token? {
- selectedWorkspaceFindingsObservation
- }
-
- var observationForExpectedRenderedStateForTesting: PortableObservationTracking.Token? {
- let expectedSelection = expectedRenderedStateForTesting.selection
- if displayedSelectionForTesting != expectedSelection {
- return selectionObservation
- }
- switch expectedSelection {
- case .job:
- return selectedJobObservation ?? selectionObservation
- case .workspaceSection:
- return selectedWorkspaceFindingsObservation ?? selectionObservation
- case nil:
- return selectionObservation
+ @MainActor
+ extension ReviewMonitorTransportViewController {
+ struct RenderSnapshotForTesting: Sendable, Equatable {
+ let log: String
+ let isShowingEmptyState: Bool
}
- }
- var displayedTitleForTesting: String? {
- nil
- }
+ enum DisplayedSelectionForTesting: Sendable, Equatable {
+ case workspaceGroup(String)
+ case chat(String)
+ }
- var displayedLogForTesting: String {
- logScrollView.displayedTextForTesting
- }
+ struct RenderedStateForTesting: Sendable, Equatable {
+ let snapshot: RenderSnapshotForTesting
+ let selection: DisplayedSelectionForTesting?
+ }
- var displayedWorkspaceFindingsForTesting: String {
- workspaceFindingsView.displayedTextForTesting
- }
+ var selectionObservationForTesting: PortableObservationTracking.Token? {
+ selectionObservation
+ }
- var displayedSummaryForTesting: String? {
- nil
- }
+ var selectedChatLogTaskForTesting: Task? {
+ chatLogTarget.selectedChatLogTaskForTesting
+ }
- var isShowingEmptyStateForTesting: Bool {
- placeholderViewController.view.isHidden == false &&
- placeholderViewController.content == .noSelection
- }
+ var displayedLogForTesting: String {
+ chatLogTarget.displayedTextForTesting
+ }
- var emptyStateFrameForTesting: NSRect {
- placeholderViewController.view.frame
- }
+ var isShowingEmptyStateForTesting: Bool {
+ placeholderViewController.view.isHidden == false && placeholderViewController.content == .noSelection
+ }
- var isShowingNoFindingsStateForTesting: Bool {
- placeholderViewController.view.isHidden == false &&
- placeholderViewController.content == .noFindings
- }
+ var emptyStateFrameForTesting: NSRect {
+ placeholderViewController.view.frame
+ }
- var isShowingWorkspaceFindingsListForTesting: Bool {
- workspaceFindingsView.isShowingFindingsListForTesting
- }
+ var isShowingNoFindingsStateForTesting: Bool {
+ placeholderViewController.view.isHidden == false && placeholderViewController.content == .noFindings
+ }
- var logAppendCountForTesting: Int {
- logScrollView.appendCount
- }
+ var logAppendCountForTesting: Int {
+ chatLogTarget.appendCount
+ }
- var logReplaceCountForTesting: Int {
- logScrollView.replaceCount
- }
+ var logReplaceCountForTesting: Int {
+ chatLogTarget.replaceCount
+ }
- var logReloadCountForTesting: Int {
- logScrollView.reloadCount
- }
+ var logReloadCountForTesting: Int {
+ chatLogTarget.reloadCount
+ }
- var logAutoFollowCountForTesting: Int {
- logScrollView.autoFollowCount
- }
+ var logAutoFollowCountForTesting: Int {
+ chatLogTarget.autoFollowCount
+ }
- var logWordGlowCountForTesting: Int {
- logScrollView.wordGlowCountForTesting
- }
+ var logWordGlowCountForTesting: Int {
+ chatLogTarget.wordGlowCountForTesting
+ }
- var logWordFadeRenderingAttributeRangeCountForTesting: Int {
- logScrollView.wordFadeRenderingAttributeRangeCountForTesting
- }
+ var logWordFadeRenderingAttributeRangeCountForTesting: Int {
+ chatLogTarget.wordFadeRenderingAttributeRangeCountForTesting
+ }
- var logWordFadeStorageUsesOpaqueTextColorForTesting: Bool {
- logScrollView.wordFadeStorageUsesOpaqueTextColorForTesting
- }
+ var logWordFadeStorageUsesOpaqueTextColorForTesting: Bool {
+ chatLogTarget.wordFadeStorageUsesOpaqueTextColorForTesting
+ }
- var logWordFadeDisplayInvalidationCountForTesting: Int {
- logScrollView.wordFadeDisplayInvalidationCountForTesting
- }
+ var logWordFadeDisplayInvalidationCountForTesting: Int {
+ chatLogTarget.wordFadeDisplayInvalidationCountForTesting
+ }
- var logCommandOutputPanelCountForTesting: Int {
- logScrollView.commandOutputPanelCountForTesting
- }
+ var logCommandOutputPanelCountForTesting: Int {
+ chatLogTarget.commandOutputPanelCountForTesting
+ }
- var logTerminalDecorationRectCountForTesting: Int {
- logScrollView.terminalDecorationRectCountForTesting
- }
+ var logTerminalDecorationRectCountForTesting: Int {
+ chatLogTarget.terminalDecorationRectCountForTesting
+ }
- var logExpandedCommandOutputPanelCountForTesting: Int {
- logScrollView.expandedCommandOutputPanelCountForTesting
- }
+ var logExpandedCommandOutputPanelCountForTesting: Int {
+ chatLogTarget.expandedCommandOutputPanelCountForTesting
+ }
- var logCommandOutputPanelUsesTextKit2ForTesting: Bool {
- logScrollView.commandOutputPanelUsesTextKit2ForTesting
- }
+ var logCommandOutputPanelUsesTextKit2ForTesting: Bool {
+ chatLogTarget.commandOutputPanelUsesTextKit2ForTesting
+ }
- var logCommandOutputPanelUsesInlineAttachmentForTesting: Bool {
- logScrollView.commandOutputPanelUsesInlineAttachmentForTesting
- }
+ var logCommandOutputPanelUsesInlineAttachmentForTesting: Bool {
+ chatLogTarget.commandOutputPanelUsesInlineAttachmentForTesting
+ }
- var logCommandOutputPanelUsesButtonAttachmentForTesting: Bool {
- logScrollView.commandOutputPanelUsesButtonAttachmentForTesting
- }
+ var logCommandOutputPanelUsesButtonAttachmentForTesting: Bool {
+ chatLogTarget.commandOutputPanelUsesButtonAttachmentForTesting
+ }
- var logCollapsedCommandOutputPanelAttachmentLineHeightForTesting: CGFloat? {
- logScrollView.collapsedCommandOutputPanelAttachmentLineHeightForTesting
- }
+ var logCollapsedCommandOutputPanelAttachmentLineHeightForTesting: CGFloat? {
+ chatLogTarget.collapsedCommandOutputPanelAttachmentLineHeightForTesting
+ }
- var logCollapsedCommandOutputPanelAttachmentPayloadIsEmptyForTesting: Bool {
- logScrollView.collapsedCommandOutputPanelAttachmentPayloadIsEmptyForTesting
- }
+ var logCollapsedCommandOutputPanelAttachmentPayloadIsEmptyForTesting: Bool {
+ chatLogTarget.collapsedCommandOutputPanelAttachmentPayloadIsEmptyForTesting
+ }
- var logCommandOutputPanelUsesSystemMaterialBackgroundForTesting: Bool {
- logScrollView.commandOutputPanelUsesSystemMaterialBackgroundForTesting
- }
+ var logCommandOutputPanelUsesSystemMaterialBackgroundForTesting: Bool {
+ chatLogTarget.commandOutputPanelUsesSystemMaterialBackgroundForTesting
+ }
- var logCommandOutputPanelVisibleLineCapacityForTesting: Int {
- logScrollView.commandOutputPanelVisibleLineCapacityForTesting
- }
+ var logCommandOutputPanelVisibleLineCapacityForTesting: Int {
+ chatLogTarget.commandOutputPanelVisibleLineCapacityForTesting
+ }
- var logCommandOutputPanelResultTextForTesting: String? {
- logScrollView.commandOutputPanelResultTextForTesting
- }
+ var logCommandOutputPanelResultTextForTesting: String? {
+ chatLogTarget.commandOutputPanelResultTextForTesting
+ }
- var logCommandOutputPanelTerminalTextForTesting: String? {
- logScrollView.commandOutputPanelTerminalTextForTesting
- }
+ var logCommandOutputPanelTerminalTextForTesting: String? {
+ chatLogTarget.commandOutputPanelTerminalTextForTesting
+ }
- func logCommandOutputPanelTerminalTextForTesting(blockID: ReviewMonitorLog.BlockID) -> String? {
- logScrollView.commandOutputPanelTerminalTextForTesting(blockID: blockID)
- }
+ func logCommandOutputPanelTerminalTextForTesting(blockID: ReviewMonitorLog.BlockID) -> String? {
+ chatLogTarget.commandOutputPanelTerminalTextForTesting(blockID: blockID)
+ }
- var logCommandOutputPanelCommandLineTextForTesting: String? {
- logScrollView.commandOutputPanelCommandLineTextForTesting
- }
+ var logCommandOutputPanelCommandLineTextForTesting: String? {
+ chatLogTarget.commandOutputPanelCommandLineTextForTesting
+ }
- var logCommandOutputPanelOutputScrollTextForTesting: String? {
- logScrollView.commandOutputPanelOutputScrollTextForTesting
- }
+ var logCommandOutputPanelOutputScrollTextForTesting: String? {
+ chatLogTarget.commandOutputPanelOutputScrollTextForTesting
+ }
- var logCommandOutputPanelOutputScrollIsScrollableForTesting: Bool {
- logScrollView.commandOutputPanelOutputScrollIsScrollableForTesting
- }
+ var logCommandOutputPanelOutputScrollIsScrollableForTesting: Bool {
+ chatLogTarget.commandOutputPanelOutputScrollIsScrollableForTesting
+ }
- var logCommandOutputPanelOutputScrollUsesHorizontalScrollingForTesting: Bool {
- logScrollView.commandOutputPanelOutputScrollUsesHorizontalScrollingForTesting
- }
+ var logCommandOutputPanelOutputScrollUsesHorizontalScrollingForTesting: Bool {
+ chatLogTarget.commandOutputPanelOutputScrollUsesHorizontalScrollingForTesting
+ }
- var logCommandOutputPanelOutputScrollVerticalOffsetForTesting: CGFloat? {
- logScrollView.commandOutputPanelOutputScrollVerticalOffsetForTesting
- }
+ var logCommandOutputPanelOutputScrollVerticalOffsetForTesting: CGFloat? {
+ chatLogTarget.commandOutputPanelOutputScrollVerticalOffsetForTesting
+ }
- var logCommandOutputPanelOutputScrollMaximumVerticalOffsetForTesting: CGFloat? {
- logScrollView.commandOutputPanelOutputScrollMaximumVerticalOffsetForTesting
- }
+ var logCommandOutputPanelOutputScrollMaximumVerticalOffsetForTesting: CGFloat? {
+ chatLogTarget.commandOutputPanelOutputScrollMaximumVerticalOffsetForTesting
+ }
- func scrollCommandOutputPanelOutputForTesting(deltaY: CGFloat) -> Bool {
- logScrollView.scrollCommandOutputPanelOutputForTesting(deltaY: deltaY)
- }
+ func scrollCommandOutputPanelOutputForTesting(deltaY: CGFloat) -> Bool {
+ chatLogTarget.scrollCommandOutputPanelOutputForTesting(deltaY: deltaY)
+ }
- var logCommandOutputPanelOutputHitTestTargetsTextViewForTesting: Bool {
- logScrollView.commandOutputPanelOutputHitTestTargetsTextViewForTesting
- }
+ var logCommandOutputPanelOutputHitTestTargetsTextViewForTesting: Bool {
+ chatLogTarget.commandOutputPanelOutputHitTestTargetsTextViewForTesting
+ }
- func logFinderRectsForTesting(_ range: NSRange) -> [NSRect] {
- logScrollView.finderRectsForTesting(range)
- }
+ func logFinderRectsForTesting(_ range: NSRange) -> [NSRect] {
+ chatLogTarget.finderRectsForTesting(range)
+ }
- var logFirstCommandOutputPanelRectForTesting: NSRect? {
- logScrollView.firstCommandOutputPanelRectForTesting
- }
+ var logFirstCommandOutputPanelRectForTesting: NSRect? {
+ chatLogTarget.firstCommandOutputPanelRectForTesting
+ }
- var logCommandOutputPanelToggleSymbolNameForTesting: String? {
- logScrollView.commandOutputPanelToggleSymbolNameForTesting
- }
+ var logCommandOutputPanelToggleSymbolNameForTesting: String? {
+ chatLogTarget.commandOutputPanelToggleSymbolNameForTesting
+ }
- var logCommandOutputPanelLeadingAlignmentDeltaForTesting: CGFloat? {
- logScrollView.commandOutputPanelLeadingAlignmentDeltaForTesting
- }
+ var logCommandOutputPanelLeadingAlignmentDeltaForTesting: CGFloat? {
+ chatLogTarget.commandOutputPanelLeadingAlignmentDeltaForTesting
+ }
- var logCommandOutputPanelChevronSizeDeltaForTesting: CGFloat? {
- logScrollView.commandOutputPanelChevronSizeDeltaForTesting
- }
+ var logCommandOutputPanelChevronSizeDeltaForTesting: CGFloat? {
+ chatLogTarget.commandOutputPanelChevronSizeDeltaForTesting
+ }
- var logCommandOutputPanelChevronVerticalAlignmentDeltaForTesting: CGFloat? {
- logScrollView.commandOutputPanelChevronVerticalAlignmentDeltaForTesting
- }
+ var logCommandOutputPanelChevronVerticalAlignmentDeltaForTesting: CGFloat? {
+ chatLogTarget.commandOutputPanelChevronVerticalAlignmentDeltaForTesting
+ }
- func logHitTestTargetsDocumentViewForFirstOccurrenceForTesting(_ text: String) -> Bool {
- logScrollView.hitTestTargetsDocumentViewForFirstLogOccurrenceForTesting(text)
- }
+ func logHitTestTargetsDocumentViewForFirstOccurrenceForTesting(_ text: String) -> Bool {
+ chatLogTarget.hitTestTargetsDocumentViewForFirstLogOccurrenceForTesting(text)
+ }
- func toggleFirstLogCommandOutputPanelForTesting() {
- logScrollView.toggleFirstCommandOutputPanelForTesting()
- }
+ func toggleFirstLogCommandOutputPanelForTesting() {
+ chatLogTarget.toggleFirstCommandOutputPanelForTesting()
+ }
- @discardableResult
- func clickFirstLogCommandOutputPanelHeaderForTesting() -> Bool {
- logScrollView.clickFirstCommandOutputPanelHeaderForTesting()
- }
+ @discardableResult
+ func clickFirstLogCommandOutputPanelHeaderForTesting() -> Bool {
+ chatLogTarget.clickFirstCommandOutputPanelHeaderForTesting()
+ }
- @discardableResult
- func clickLogCommandOutputPanelHeaderForTesting(blockID: ReviewMonitorLog.BlockID) -> Bool {
- logScrollView.clickCommandOutputPanelHeaderForTesting(blockID: blockID)
- }
+ @discardableResult
+ func clickLogCommandOutputPanelHeaderForTesting(blockID: ReviewMonitorLog.BlockID) -> Bool {
+ chatLogTarget.clickCommandOutputPanelHeaderForTesting(blockID: blockID)
+ }
- func completeLogWordGlowAnimationsForTesting() {
- logScrollView.completeWordGlowAnimationsForTesting()
- }
+ func completeLogWordGlowAnimationsForTesting() {
+ chatLogTarget.completeWordGlowAnimationsForTesting()
+ }
- func advanceLogWordGlowAnimationsAfterInitialDelayForTesting(_ delay: TimeInterval) {
- logScrollView.advanceWordGlowAnimationsAfterInitialDelayForTesting(delay)
- }
+ func advanceLogWordGlowAnimationsAfterInitialDelayForTesting(_ delay: TimeInterval) {
+ chatLogTarget.advanceWordGlowAnimationsAfterInitialDelayForTesting(delay)
+ }
- func setLogReduceMotionForTesting(_ reduceMotion: Bool?) {
- logScrollView.setReduceMotionForTesting(reduceMotion)
- }
+ func setLogReduceMotionForTesting(_ reduceMotion: Bool?) {
+ chatLogTarget.setReduceMotionForTesting(reduceMotion)
+ }
- var logUsesCustomTextKit2SurfaceForTesting: Bool {
- logScrollView.usesCustomTextKit2SurfaceForTesting
- }
+ var logUsesCustomTextKit2SurfaceForTesting: Bool {
+ chatLogTarget.usesCustomTextKit2SurfaceForTesting
+ }
- var logUsesTextViewForTesting: Bool {
- logScrollView.usesTextViewForTesting
- }
+ var logUsesTextViewForTesting: Bool {
+ chatLogTarget.usesTextViewForTesting
+ }
- var logUsesLegacyLayoutManagerForTesting: Bool {
- logScrollView.usesLegacyLayoutManagerForTesting
- }
+ var logUsesLogLayoutManagerForTesting: Bool {
+ chatLogTarget.usesLogLayoutManagerForTesting
+ }
- var logIsEditableForTesting: Bool {
- logScrollView.isEditableForTesting
- }
+ var logIsEditableForTesting: Bool {
+ chatLogTarget.isEditableForTesting
+ }
- var logIsSelectableForTesting: Bool {
- logScrollView.isSelectableForTesting
- }
+ var logIsSelectableForTesting: Bool {
+ chatLogTarget.isSelectableForTesting
+ }
- var logUsesFindBarForTesting: Bool {
- logScrollView.usesFindBarForTesting
- }
+ var logUsesFindBarForTesting: Bool {
+ chatLogTarget.usesFindBarForTesting
+ }
- var logIsIncrementalSearchingEnabledForTesting: Bool {
- logScrollView.isIncrementalSearchingEnabledForTesting
- }
+ var logIsIncrementalSearchingEnabledForTesting: Bool {
+ chatLogTarget.isIncrementalSearchingEnabledForTesting
+ }
- var logFindBarVisibleForTesting: Bool {
- logScrollView.isFindBarVisibleForTesting
- }
+ var logFindBarVisibleForTesting: Bool {
+ chatLogTarget.isFindBarVisibleForTesting
+ }
- var logTextFinderIdentifierForTesting: ObjectIdentifier {
- logScrollView.textFinderIdentifierForTesting
- }
+ var logTextFinderIdentifierForTesting: ObjectIdentifier {
+ chatLogTarget.textFinderIdentifierForTesting
+ }
- var logFindVisibleCharacterRangesForTesting: [NSRange] {
- logScrollView.findVisibleCharacterRangesForTesting
- }
+ var logFindVisibleCharacterRangesForTesting: [NSRange] {
+ chatLogTarget.findVisibleCharacterRangesForTesting
+ }
- var logFindStringLengthForTesting: Int {
- logScrollView.findStringLengthForTesting
- }
+ var logFindStringLengthForTesting: Int {
+ chatLogTarget.findStringLengthForTesting
+ }
- var logFindClientUsesSnapshotForTesting: Bool {
- logScrollView.findClientUsesSnapshotForTesting
- }
+ var logFindClientUsesSnapshotForTesting: Bool {
+ chatLogTarget.findClientUsesSnapshotForTesting
+ }
- var logFindClientSnapshotMapsToDocumentForTesting: Bool {
- logScrollView.findClientSnapshotMapsToDocumentForTesting
- }
+ var logFindClientSnapshotMapsToDocumentForTesting: Bool {
+ chatLogTarget.findClientSnapshotMapsToDocumentForTesting
+ }
- var logFindClientFirstSelectedRangeForTesting: NSRange {
- logScrollView.findClientFirstSelectedRangeForTesting
- }
+ var logFindClientFirstSelectedRangeForTesting: NSRange {
+ chatLogTarget.findClientFirstSelectedRangeForTesting
+ }
- var logHasActiveFindQueryForTesting: Bool {
- logScrollView.hasActiveFindQueryForTesting
- }
+ var logHasActiveFindQueryForTesting: Bool {
+ chatLogTarget.hasActiveFindQueryForTesting
+ }
- var logVisibleFindBarSearchStringForTesting: String? {
- logScrollView.visibleFindBarSearchStringForTesting
- }
+ var logVisibleFindBarSearchStringForTesting: String? {
+ chatLogTarget.visibleFindBarSearchStringForTesting
+ }
- @discardableResult
- func setLogVisibleFindBarSearchStringForTesting(_ string: String) -> Bool {
- logScrollView.setVisibleFindBarSearchStringForTesting(string)
- }
+ @discardableResult
+ func setLogVisibleFindBarSearchStringForTesting(_ string: String) -> Bool {
+ chatLogTarget.setVisibleFindBarSearchStringForTesting(string)
+ }
- var logFindIndicatorInvalidationCountForTesting: Int {
- logScrollView.findIndicatorInvalidationCountForTesting
- }
+ var logFindIndicatorInvalidationCountForTesting: Int {
+ chatLogTarget.findIndicatorInvalidationCountForTesting
+ }
- var logFindIncrementalMatchRangeCountForTesting: Int {
- logScrollView.findIncrementalMatchRangeCountForTesting
- }
+ var logFindIncrementalMatchRangeCountForTesting: Int {
+ chatLogTarget.findIncrementalMatchRangeCountForTesting
+ }
- var logFindBarContainerContentViewIsTextContentViewForTesting: Bool {
- logScrollView.findBarContainerContentViewIsTextContentViewForTesting
- }
+ var logFindBarContainerContentViewIsTextContentViewForTesting: Bool {
+ chatLogTarget.findBarContainerContentViewIsTextContentViewForTesting
+ }
- var logFindIncrementalSearchUsesSystemHighlightingForTesting: Bool {
- logScrollView.findIncrementalSearchUsesSystemHighlightingForTesting
- }
+ var logFindIncrementalSearchUsesSystemHighlightingForTesting: Bool {
+ chatLogTarget.findIncrementalSearchUsesSystemHighlightingForTesting
+ }
- var logHitTestTargetsDocumentViewForTesting: Bool {
- logScrollView.hitTestTargetsDocumentViewForTesting
- }
+ var logHitTestTargetsDocumentViewForTesting: Bool {
+ chatLogTarget.hitTestTargetsDocumentViewForTesting
+ }
- var logWritingToolsDisabledForTesting: Bool {
- logScrollView.writingToolsDisabledForTesting
- }
+ var logWritingToolsDisabledForTesting: Bool {
+ chatLogTarget.writingToolsDisabledForTesting
+ }
- var logOverlayScrollerHideRequestCountForTesting: Int {
- logScrollView.overlayScrollerHideRequestCountForTesting
- }
+ var logOverlayScrollerHideRequestCountForTesting: Int {
+ chatLogTarget.overlayScrollerHideRequestCountForTesting
+ }
- var logRenderIsIdleForTesting: Bool {
- appliedLogRenderGeneration == logRenderGeneration
- }
+ var logRenderIsIdleForTesting: Bool {
+ chatLogTarget.logRenderIsIdleForTesting
+ }
- var logFrameForTesting: NSRect {
- logScrollView.frame
- }
+ var logFrameForTesting: NSRect {
+ chatLogTarget.frame
+ }
- var viewFrameForTesting: NSRect {
- view.frame
- }
+ var viewFrameForTesting: NSRect {
+ view.frame
+ }
- var viewBoundsForTesting: NSRect {
- view.bounds
- }
+ var viewBoundsForTesting: NSRect {
+ view.bounds
+ }
- var safeAreaFrameForTesting: NSRect {
- view.safeAreaRect
- }
+ var safeAreaFrameForTesting: NSRect {
+ view.safeAreaRect
+ }
- var displayedViewFrameForTesting: NSRect {
- logScrollView.frame
- }
+ var displayedViewFrameForTesting: NSRect {
+ chatLogTarget.frame
+ }
- var activeDisplayedViewConstraintCountForTesting: Int {
- displayedContentConstraints.filter(\.isActive).count
- }
+ var activeDisplayedViewConstraintCountForTesting: Int {
+ displayedContentConstraints.filter(\.isActive).count
+ }
- var renderSnapshotForTesting: RenderSnapshotForTesting {
- if isShowingEmptyStateForTesting {
+ var renderSnapshotForTesting: RenderSnapshotForTesting {
+ if isShowingEmptyStateForTesting {
+ return .init(
+ log: "",
+ isShowingEmptyState: true
+ )
+ }
return .init(
- title: nil,
- summary: nil,
- log: "",
- isShowingEmptyState: true
+ log: displayedLogForTesting,
+ isShowingEmptyState: false
)
}
- return .init(
- title: displayedTitleForTesting,
- summary: displayedSummaryForTesting,
- log: displayedLogForTesting,
- isShowingEmptyState: false
- )
- }
-
- var renderedStateForTesting: RenderedStateForTesting {
- .init(
- snapshot: renderSnapshotForTesting,
- selection: displayedSelectionForTesting
- )
- }
- var expectedRenderSnapshotForTesting: RenderSnapshotForTesting {
- switch uiState.selection {
- case .job(let job):
+ var renderedStateForTesting: RenderedStateForTesting {
.init(
- title: nil,
- summary: nil,
- log: {
- let timelineDocument = ReviewTimelineDocumentRenderer().document(from: job.timeline)
- var projection = ReviewMonitorTimelineLogProjection()
- let document = projection.render(timelineDocument: timelineDocument)
- return logScrollView.displayTextForTesting(sourceDocument: document)
- }(),
- isShowingEmptyState: false
- )
- case .workspaceSection:
- .init(
- title: nil,
- summary: nil,
- log: "",
- isShowingEmptyState: false
- )
- case nil:
- .init(
- title: nil,
- summary: nil,
- log: "",
- isShowingEmptyState: true
+ snapshot: renderSnapshotForTesting,
+ selection: displayedSelectionForTesting
)
}
- }
-
- var expectedRenderedStateForTesting: RenderedStateForTesting {
- .init(
- snapshot: expectedRenderSnapshotForTesting,
- selection: expectedDisplayedSelectionForTesting
- )
- }
- private var displayedSelectionForTesting: DisplayedSelectionForTesting? {
- switch displayedSelection {
- case .job(let id):
- .job(id)
- case .workspaceSection(let id):
- .workspaceSection(id)
- case nil:
- nil
+ private var displayedSelectionForTesting: DisplayedSelectionForTesting? {
+ switch displayedSelection {
+ case .workspaceGroup(let id):
+ .workspaceGroup(id.rawValue)
+ case .chat(let id):
+ .chat(id.rawValue)
+ case nil:
+ nil
+ }
}
- }
- private var expectedDisplayedSelectionForTesting: DisplayedSelectionForTesting? {
- switch uiState.selection {
- case .job(let job):
- .job(job.id)
- case .workspaceSection(let section):
- .workspaceSection(section.id)
- case nil:
- nil
+ func scrollLogToTopForTesting() {
+ chatLogTarget.scrollToTopForTesting()
}
- }
-
- var workspaceFindingSnapshotForTesting: WorkspaceFindingSnapshotForTesting {
- .init(
- text: displayedWorkspaceFindingsForTesting,
- isShowingNoFindingsState: isShowingNoFindingsStateForTesting,
- isShowingFindingsList: isShowingWorkspaceFindingsListForTesting
- )
- }
-
- var workspaceFindingsContentWidthForTesting: CGFloat {
- view.layoutSubtreeIfNeeded()
- return workspaceFindingsView.contentWidthForTesting
- }
-
- var workspaceFindingsFrameForTesting: NSRect {
- workspaceFindingsView.frame
- }
-
- var workspaceFindingsTextContainerWidthForTesting: CGFloat {
- view.layoutSubtreeIfNeeded()
- return workspaceFindingsView.textContainerWidthForTesting
- }
- var workspaceFindingsScrollFrameForTesting: NSRect {
- workspaceFindingsView.scrollFrameForTesting
- }
-
- var workspaceFindingsDocumentFrameForTesting: NSRect {
- workspaceFindingsView.documentFrameForTesting
- }
-
- var workspaceFindingsNoFindingsFrameForTesting: NSRect {
- placeholderViewController.view.frame
- }
-
- var workspaceFindingsContentInsetsForTesting: NSEdgeInsets {
- workspaceFindingsView.contentInsetsForTesting
- }
-
- var workspaceFindingsVerticalScrollOffsetForTesting: CGFloat {
- workspaceFindingsView.verticalScrollOffsetForTesting
- }
-
- var workspaceFindingsMinimumVerticalScrollOffsetForTesting: CGFloat {
- workspaceFindingsView.minimumVerticalScrollOffsetForTesting
- }
-
- var workspaceFindingsMaximumVerticalScrollOffsetForTesting: CGFloat {
- workspaceFindingsView.maximumVerticalScrollOffsetForTesting
- }
-
- var workspaceFindingsAutomaticallyAdjustsContentInsetsForTesting: Bool {
- workspaceFindingsView.automaticallyAdjustsContentInsetsForTesting
- }
-
- var workspaceFindingsTextIsSelectableForTesting: Bool {
- workspaceFindingsView.isTextSelectableForTesting
- }
-
- var workspaceFindingsTextIsEditableForTesting: Bool {
- workspaceFindingsView.isTextEditableForTesting
- }
-
- var workspaceFindingsUsesFindBarForTesting: Bool {
- workspaceFindingsView.usesFindBarForTesting
- }
-
- var workspaceFindingsIsIncrementalSearchingEnabledForTesting: Bool {
- workspaceFindingsView.isIncrementalSearchingEnabledForTesting
- }
-
- var workspaceFindingsFindBarVisibleForTesting: Bool {
- workspaceFindingsView.isFindBarVisibleForTesting
- }
-
- var workspaceFindingsPriorityPrefixCountForTesting: Int {
- workspaceFindingsView.priorityPrefixCountForTesting
- }
-
- var workspaceFindingsTextAttachmentCountForTesting: Int {
- workspaceFindingsView.textAttachmentCountForTesting
- }
-
- var workspaceFindingsThreadBackgroundRangeCountForTesting: Int {
- workspaceFindingsView.threadBackgroundRangeCountForTesting
- }
-
- var workspaceFindingsAccessibilityValueForTesting: String? {
- workspaceFindingsView.accessibilityValueForTesting
- }
-
- var workspaceFindingsRenderedStorageStringForTesting: String {
- workspaceFindingsView.renderedStorageStringForTesting
- }
+ func scrollLogToOffsetForTesting(_ y: CGFloat) {
+ chatLogTarget.scrollToOffsetForTesting(y)
+ }
- func scrollLogToTopForTesting() {
- logScrollView.scrollToTopForTesting()
- }
+ var logVerticalScrollOffsetForTesting: CGFloat {
+ chatLogTarget.verticalScrollOffsetForTesting
+ }
- func scrollLogToOffsetForTesting(_ y: CGFloat) {
- logScrollView.scrollToOffsetForTesting(y)
- }
+ var logViewportHeightForTesting: CGFloat {
+ chatLogTarget.viewportHeightForTesting
+ }
- var logVerticalScrollOffsetForTesting: CGFloat {
- logScrollView.verticalScrollOffsetForTesting
- }
+ var logMinimumVerticalScrollOffsetForTesting: CGFloat {
+ chatLogTarget.minimumVerticalScrollOffsetForTesting
+ }
- var logViewportHeightForTesting: CGFloat {
- logScrollView.viewportHeightForTesting
- }
+ var logMaximumVerticalScrollOffsetForTesting: CGFloat {
+ chatLogTarget.maximumVerticalScrollOffsetForTesting
+ }
- var logMinimumVerticalScrollOffsetForTesting: CGFloat {
- logScrollView.minimumVerticalScrollOffsetForTesting
- }
+ var logTextContentFrameForTesting: NSRect {
+ chatLogTarget.textContentFrameForTesting
+ }
- var logMaximumVerticalScrollOffsetForTesting: CGFloat {
- logScrollView.maximumVerticalScrollOffsetForTesting
- }
+ var logDocumentViewFrameForTesting: NSRect {
+ chatLogTarget.documentViewFrameForTesting
+ }
- var logTextContentFrameForTesting: NSRect {
- logScrollView.textContentFrameForTesting
- }
+ var logContentInsetsForTesting: NSEdgeInsets {
+ chatLogTarget.contentInsetsForTesting
+ }
- var logDocumentViewFrameForTesting: NSRect {
- logScrollView.documentViewFrameForTesting
- }
+ var logAutomaticallyAdjustsContentInsetsForTesting: Bool {
+ chatLogTarget.automaticallyAdjustsContentInsetsForTesting
+ }
- var logContentInsetsForTesting: NSEdgeInsets {
- logScrollView.contentInsetsForTesting
- }
+ var logTextContainerSizeForTesting: NSSize {
+ chatLogTarget.textContainerSizeForTesting
+ }
- var logAutomaticallyAdjustsContentInsetsForTesting: Bool {
- logScrollView.automaticallyAdjustsContentInsetsForTesting
- }
+ var logTextContainerInsetForTesting: NSSize {
+ chatLogTarget.textContainerInsetForTesting
+ }
- var logTextContainerSizeForTesting: NSSize {
- logScrollView.textContainerSizeForTesting
- }
+ var logVisibleFragmentViewCountForTesting: Int {
+ chatLogTarget.visibleFragmentViewCountForTesting
+ }
- var logTextContainerInsetForTesting: NSSize {
- logScrollView.textContainerInsetForTesting
- }
+ var logVisibleFragmentViewCountWithoutForcingLayoutForTesting: Int {
+ chatLogTarget.visibleFragmentViewCountWithoutForcingLayoutForTesting
+ }
- var logVisibleFragmentViewCountForTesting: Int {
- logScrollView.visibleFragmentViewCountForTesting
- }
+ var logVisibleFragmentBoundsForTesting: NSRect {
+ chatLogTarget.visibleFragmentBoundsForTesting
+ }
- var logVisibleFragmentViewCountWithoutForcingLayoutForTesting: Int {
- logScrollView.visibleFragmentViewCountWithoutForcingLayoutForTesting
- }
+ var logVisibleFragmentBoundsWithoutForcingLayoutForTesting: NSRect {
+ chatLogTarget.visibleFragmentBoundsWithoutForcingLayoutForTesting
+ }
- var logVisibleFragmentBoundsForTesting: NSRect {
- logScrollView.visibleFragmentBoundsForTesting
- }
+ var logStaleFragmentViewCountForTesting: Int {
+ chatLogTarget.staleFragmentViewCountForTesting
+ }
- var logVisibleFragmentBoundsWithoutForcingLayoutForTesting: NSRect {
- logScrollView.visibleFragmentBoundsWithoutForcingLayoutForTesting
- }
+ var logProgrammaticScrollCountForTesting: Int {
+ chatLogTarget.programmaticScrollCountForTesting
+ }
- var logStaleFragmentViewCountForTesting: Int {
- logScrollView.staleFragmentViewCountForTesting
- }
+ var logAccessibilityValueForTesting: String? {
+ chatLogTarget.accessibilityValueForTesting
+ }
- var logProgrammaticScrollCountForTesting: Int {
- logScrollView.programmaticScrollCountForTesting
- }
+ var logSelectedTextForTesting: String? {
+ chatLogTarget.selectedTextForTesting
+ }
- var logAccessibilityValueForTesting: String? {
- logScrollView.accessibilityValueForTesting
- }
+ var logSelectedRangeForTesting: NSRange {
+ chatLogTarget.selectedRangeForTesting
+ }
- var logSelectedTextForTesting: String? {
- logScrollView.selectedTextForTesting
- }
+ var logFindStringForTesting: String {
+ chatLogTarget.findStringForTesting
+ }
- var logSelectedRangeForTesting: NSRange {
- logScrollView.selectedRangeForTesting
- }
+ func selectAllLogForTesting() {
+ chatLogTarget.selectAllForTesting()
+ }
- var logFindStringForTesting: String {
- logScrollView.findStringForTesting
- }
+ func setSelectedLogRangeForTesting(_ range: NSRange) {
+ chatLogTarget.setSelectedLogRangeForTesting(range)
+ }
- func selectAllLogForTesting() {
- logScrollView.selectAllForTesting()
- }
+ var logDocumentViewExportsUserInterfaceValidationForTesting: Bool {
+ chatLogTarget.documentViewExportsUserInterfaceValidationForTesting
+ }
- func setSelectedLogRangeForTesting(_ range: NSRange) {
- logScrollView.setSelectedLogRangeForTesting(range)
- }
+ func validateLogDocumentUserInterfaceItemForTesting(_ item: NSValidatedUserInterfaceItem) -> Bool {
+ chatLogTarget.validateDocumentUserInterfaceItemForTesting(item)
+ }
- var logDocumentViewExportsUserInterfaceValidationForTesting: Bool {
- logScrollView.documentViewExportsUserInterfaceValidationForTesting
- }
+ func clearLogFinderSelectedRangesForTesting() {
+ chatLogTarget.clearFinderSelectedRangesForTesting()
+ }
- func validateLogDocumentUserInterfaceItemForTesting(_ item: NSValidatedUserInterfaceItem) -> Bool {
- logScrollView.validateDocumentUserInterfaceItemForTesting(item)
- }
+ func setLogFinderSelectedRangeForTesting(_ range: NSRange) {
+ chatLogTarget.setFinderSelectedRangeForTesting(range)
+ }
- func clearLogFinderSelectedRangesForTesting() {
- logScrollView.clearFinderSelectedRangesForTesting()
- }
+ func simulateLogFinderEmptySelectedRangesForTesting() {
+ chatLogTarget.simulateFinderEmptySelectedRangesForTesting()
+ }
- func setLogFinderSelectedRangeForTesting(_ range: NSRange) {
- logScrollView.setFinderSelectedRangeForTesting(range)
- }
+ func performLogKeyboardCommandForTesting(_ selector: Selector) {
+ chatLogTarget.performKeyboardCommandForTesting(selector)
+ }
- func simulateLogFinderEmptySelectedRangesForTesting() {
- logScrollView.simulateFinderEmptySelectedRangesForTesting()
- }
+ @discardableResult
+ func renderLogForTesting(text: String, allowIncrementalUpdate: Bool) -> Bool {
+ chatLogTarget.renderForTesting(text: text, allowIncrementalUpdate: allowIncrementalUpdate)
+ }
- func performLogKeyboardCommandForTesting(_ selector: Selector) {
- logScrollView.performKeyboardCommandForTesting(selector)
- }
+ @discardableResult
+ func renderLogDocumentForTesting(
+ _ sourceDocument: ReviewMonitorLog.Document,
+ target: DisplayedSelectionForTesting? = nil,
+ allowIncrementalUpdate: Bool
+ ) -> Bool {
+ let chatID: CodexThreadID
+ switch target ?? displayedSelectionForTesting {
+ case .chat(let id):
+ chatID = CodexThreadID(rawValue: id)
+ case .workspaceGroup, nil:
+ return false
+ }
+ return chatLogTarget.renderLogDocumentForTesting(
+ sourceDocument,
+ chatID: chatID,
+ allowIncrementalUpdate: allowIncrementalUpdate
+ )
+ }
- @discardableResult
- func renderLogForTesting(text: String, allowIncrementalUpdate: Bool) -> Bool {
- logScrollView.renderForTesting(text: text, allowIncrementalUpdate: allowIncrementalUpdate)
- }
+ func bindLogRenderTargetForTesting(_ target: DisplayedSelectionForTesting) {
+ switch target {
+ case .chat(let id):
+ let chatID = CodexThreadID(rawValue: id)
+ chatLogTarget.bindLogRenderTargetForTesting(chatID)
+ boundChatID = chatID
+ boundModelContext = nil
+ hidePlaceholder()
+ chatLogTarget.view.isHidden = false
+ displayedSelection = .chat(chatID)
+ case .workspaceGroup:
+ break
+ }
+ }
- func copyLogSelectionForTesting() {
- logScrollView.copySelectionForTesting()
- }
+ func copyLogSelectionForTesting() {
+ chatLogTarget.copySelectionForTesting()
+ }
- func beginLogLiveResizeForTesting() {
- logScrollView.beginLiveResizeForTesting()
- }
+ func beginLogLiveResizeForTesting() {
+ chatLogTarget.beginLiveResizeForTesting()
+ }
- func endLogLiveResizeForTesting() {
- logScrollView.endLiveResizeForTesting()
- }
+ func endLogLiveResizeForTesting() {
+ chatLogTarget.endLiveResizeForTesting()
+ }
- func scrollLogToBottomForTesting() {
- logScrollView.scrollToBottomForTesting()
- }
+ func scrollLogToBottomForTesting() {
+ chatLogTarget.scrollToBottomForTesting()
+ }
- var isLogPinnedToBottomForTesting: Bool {
- logScrollView.isPinnedToBottomForTesting
- }
+ var isLogPinnedToBottomForTesting: Bool {
+ chatLogTarget.isPinnedToBottomForTesting
+ }
- func setLogScrollerStyleForTesting(_ style: NSScroller.Style) {
- logScrollView.setScrollerStyleForTesting(style)
- }
+ func setLogScrollerStyleForTesting(_ style: NSScroller.Style) {
+ chatLogTarget.setScrollerStyleForTesting(style)
+ }
- func setLogOverlayScrollersShownForTesting(_ isShown: Bool?) {
- logScrollView.setOverlayScrollersShownForTesting(isShown)
- }
+ func setLogOverlayScrollersShownForTesting(_ isShown: Bool?) {
+ chatLogTarget.setOverlayScrollersShownForTesting(isShown)
+ }
- func setLogOverlayScrollerBridgeModeForTesting(
- _ mode: ReviewMonitorLogScrollView.OverlayScrollerBridgeModeForTesting
- ) {
- logScrollView.setOverlayScrollerBridgeModeForTesting(mode)
+ func setLogOverlayScrollerBridgeModeForTesting(
+ _ mode: ReviewMonitorCodexChatLogTarget.OverlayScrollerBridgeModeForTesting
+ ) {
+ chatLogTarget.setOverlayScrollerBridgeModeForTesting(mode)
+ }
}
-}
#endif
diff --git a/Sources/ReviewUI/Detail/ReviewMonitorWorkspaceFindingsView.swift b/Sources/ReviewUI/Detail/ReviewMonitorWorkspaceFindingsView.swift
deleted file mode 100644
index 840dbc34..00000000
--- a/Sources/ReviewUI/Detail/ReviewMonitorWorkspaceFindingsView.swift
+++ /dev/null
@@ -1,929 +0,0 @@
-import AppKit
-#if DEBUG
-import SwiftUI
-#endif
-
-@MainActor
-final class ReviewMonitorWorkspaceFindingsView: NSView {
- private final class DocumentContainerView: NSView {
- var measuredTextHeight: @MainActor () -> CGFloat = { 0 }
-
- override var isFlipped: Bool {
- true
- }
-
- override var intrinsicContentSize: NSSize {
- NSSize(
- width: NSView.noIntrinsicMetric,
- height: measuredTextHeight()
- )
- }
- }
-
- private final class FindingsTextView: NSTextView {
- var threadCharacterRanges: [NSRange] = []
-
- func threadBackgroundRects() -> [NSRect] {
- guard let layoutManager,
- let textContainer
- else {
- return []
- }
-
- layoutManager.ensureLayout(for: textContainer)
- return threadCharacterRanges.compactMap { characterRange in
- let glyphRange = layoutManager.glyphRange(
- forCharacterRange: characterRange,
- actualCharacterRange: nil
- )
- guard glyphRange.length > 0,
- let backgroundRect = threadBackgroundRect(
- forGlyphRange: glyphRange,
- in: textContainer,
- layoutManager: layoutManager
- )
- else {
- return nil
- }
- return backgroundRect
- }
- }
-
- private func threadBackgroundRect(
- forGlyphRange glyphRange: NSRange,
- in textContainer: NSTextContainer,
- layoutManager: NSLayoutManager
- ) -> NSRect? {
- var usedRect = NSRect.null
- layoutManager.enumerateLineFragments(forGlyphRange: glyphRange) { _, lineUsedRect, container, lineGlyphRange, _ in
- guard container === textContainer,
- NSIntersectionRange(lineGlyphRange, glyphRange).length > 0
- else {
- return
- }
-
- var lineRect = lineUsedRect
- lineRect.origin.x = 0
- lineRect.size.width = textContainer.containerSize.width
- usedRect = usedRect.union(lineRect)
- }
-
- guard usedRect.isNull == false else {
- return nil
- }
-
- return NSRect(
- x: textContainerOrigin.x + usedRect.minX - Layout.threadBackgroundPadding,
- y: textContainerOrigin.y + usedRect.minY - Layout.threadBackgroundPadding,
- width: usedRect.width + Layout.threadBackgroundPadding * 2,
- height: usedRect.height + Layout.threadBackgroundPadding * 2
- )
- }
- }
-
- private enum Layout {
- static let bodyLineSpacing: CGFloat = 2
- static let sectionTitleBackgroundGap: CGFloat = 4
- static let titleBodySpacing: CGFloat = 4
- static let findingSpacing: CGFloat = 12
- static let threadSpacing: CGFloat = 28
- static let threadBackgroundPadding: CGFloat = 8
- static let threadBackgroundCornerRadius: CGFloat = 8
- static let sectionTitleSpacing = threadBackgroundPadding + sectionTitleBackgroundGap
- }
-
- private struct RenderedText {
- var attributedString: NSAttributedString
- var threadRanges: [NSRange]
- }
-
- struct Entry {
- var threadID: String
- var targetSummary: String? = nil
- var priority: Int?
- var title: String
- var body: String
- var locationText: String? = nil
-
- var displayText: String {
- [
- displayTitle,
- body,
- locationText,
- ]
- .compactMap { value in
- let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- return trimmed.isEmpty ? nil : trimmed
- }
- .joined(separator: "\n")
- }
-
- var sectionTitle: String? {
- let trimmed = targetSummary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- return trimmed.isEmpty ? nil : trimmed
- }
-
- var priorityText: String? {
- guard let priority,
- (0...3).contains(priority)
- else {
- return nil
- }
- return "P\(priority)"
- }
-
- var displayTitle: String {
- guard let priorityText else {
- return titleText
- }
- return "[\(priorityText)] \(titleText)"
- }
-
- var titleText: String {
- let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
- guard let priorityText
- else {
- return trimmedTitle
- }
-
- let priorityPrefix = "[\(priorityText)]"
- guard trimmedTitle.hasPrefix(priorityPrefix) else {
- return trimmedTitle
- }
-
- return trimmedTitle
- .dropFirst(priorityPrefix.count)
- .trimmingCharacters(in: .whitespacesAndNewlines)
- }
- }
-
- private let scrollView = NSScrollView()
- private let documentContainerView = DocumentContainerView()
- private let textView: FindingsTextView
- private var threadBackgroundViews: [NSVisualEffectView] = []
- private let storage: NSTextStorage
- private let layoutManager: NSLayoutManager
- private let textContainer: NSTextContainer
- private var displayedText = ""
- private var displayedThreadSignature = ""
-
- override init(frame frameRect: NSRect) {
- let storage = NSTextStorage()
- let layoutManager = NSLayoutManager()
- let textContainer = NSTextContainer(
- size: NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude)
- )
- textContainer.widthTracksTextView = false
- textContainer.heightTracksTextView = false
- storage.addLayoutManager(layoutManager)
- layoutManager.addTextContainer(textContainer)
- layoutManager.allowsNonContiguousLayout = true
-
- let textView = FindingsTextView(frame: .zero, textContainer: textContainer)
- textView.minSize = .zero
- textView.maxSize = NSSize(
- width: CGFloat.greatestFiniteMagnitude,
- height: CGFloat.greatestFiniteMagnitude
- )
- textView.isVerticallyResizable = true
- textView.isHorizontallyResizable = false
- textView.translatesAutoresizingMaskIntoConstraints = false
-
- self.storage = storage
- self.layoutManager = layoutManager
- self.textContainer = textContainer
- self.textView = textView
- super.init(frame: frameRect)
- configureHierarchy()
- }
-
- deinit {
- NSWorkspace.shared.notificationCenter.removeObserver(self)
- }
-
- @available(*, unavailable)
- required init?(coder: NSCoder) {
- nil
- }
-
- @discardableResult
- func render(entries: [Entry]) -> Bool {
- let nextText = displayText(for: entries)
- let nextThreadSignature = entries.map { entry in
- "\(entry.threadID)\u{1F}\(entry.sectionTitle ?? "")\u{1F}\(entry.displayText)"
- }
- .joined(separator: "\u{1E}")
- guard displayedText != nextText || displayedThreadSignature != nextThreadSignature else {
- updateVisibility(hasFindings: entries.isEmpty == false)
- return false
- }
-
- displayedText = nextText
- displayedThreadSignature = nextThreadSignature
- replaceText(with: makeRenderedText(entries: entries))
- textView.setAccessibilityValue(nextText)
- updateVisibility(hasFindings: entries.isEmpty == false)
- return true
- }
-
- private func displayText(for entries: [Entry]) -> String {
- var components: [String] = []
- var currentThreadID: String?
- for entry in entries {
- if currentThreadID != entry.threadID {
- if let sectionTitle = entry.sectionTitle {
- components.append(sectionTitle)
- }
- currentThreadID = entry.threadID
- }
- components.append(entry.displayText)
- }
- return components.joined(separator: "\n\n")
- }
-
- @discardableResult
- func clear() -> Bool {
- let changed = displayedText.isEmpty == false
- || scrollView.isHidden == false
- displayedText = ""
- displayedThreadSignature = ""
- replaceText(with: RenderedText(attributedString: NSAttributedString(), threadRanges: []))
- textView.setAccessibilityValue("")
- scrollView.isHidden = true
- return changed
- }
-
- override func layout() {
- super.layout()
- invalidateDocumentLayout()
- }
-
- private func configureHierarchy() {
- translatesAutoresizingMaskIntoConstraints = false
-
- scrollView.translatesAutoresizingMaskIntoConstraints = false
- scrollView.drawsBackground = false
- scrollView.borderType = .noBorder
- scrollView.hasVerticalScroller = true
- scrollView.autohidesScrollers = true
- scrollView.automaticallyAdjustsContentInsets = true
-
- textView.textContainerInset = NSSize(width: 28, height: 24)
- textView.translatesAutoresizingMaskIntoConstraints = false
- textView.isEditable = false
- textView.isSelectable = true
- textView.isRichText = true
- textView.importsGraphics = false
- textView.usesFindBar = true
- textView.isIncrementalSearchingEnabled = true
- textView.enabledTextCheckingTypes = 0
- textView.isAutomaticDataDetectionEnabled = false
- textView.isAutomaticDashSubstitutionEnabled = false
- textView.isAutomaticQuoteSubstitutionEnabled = false
- textView.isAutomaticSpellingCorrectionEnabled = false
- textView.isAutomaticTextCompletionEnabled = false
- textView.isAutomaticTextReplacementEnabled = false
- textView.usesRuler = false
- textView.usesInspectorBar = false
- textView.drawsBackground = true
- textView.backgroundColor = .clear
- textView.textColor = .textColor
- textView.setAccessibilityIdentifier("review-monitor.workspace-findings-text")
- textView.setAccessibilityLabel("Workspace findings")
-
- documentContainerView.measuredTextHeight = { [weak self] in
- self?.measuredTextHeight() ?? 0
- }
- documentContainerView.addSubview(textView)
- scrollView.documentView = documentContainerView
-
- addSubview(scrollView)
-
- NSLayoutConstraint.activate([
- scrollView.topAnchor.constraint(equalTo: topAnchor),
- scrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
- scrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
- scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
- ])
-
- clear()
- observeAccessibilityDisplayOptions()
- }
-
- private func replaceText(with renderedText: RenderedText) {
- storage.beginEditing()
- storage.replaceCharacters(
- in: NSRange(location: 0, length: storage.length),
- with: renderedText.attributedString
- )
- storage.endEditing()
- textView.threadCharacterRanges = renderedText.threadRanges
- invalidateDocumentLayout()
- layoutSubtreeIfNeeded()
- syncThreadBackgroundViews()
- scrollToTopRespectingContentInsets()
- }
-
- private func updateVisibility(hasFindings: Bool) {
- scrollView.isHidden = hasFindings == false
- }
-
- private func invalidateDocumentLayout() {
- syncDocumentFrameToTextLayout()
- documentContainerView.invalidateIntrinsicContentSize()
- }
-
- @discardableResult
- private func syncTextContainerWidthToTextView() -> Bool {
- let targetWidth = max(0, effectiveScrollContentSize.width - textView.textContainerInset.width * 2)
- let targetSize = NSSize(width: targetWidth, height: CGFloat.greatestFiniteMagnitude)
- guard abs(textContainer.containerSize.width - targetSize.width) > 0.5 ||
- textContainer.containerSize.height != targetSize.height
- else {
- return false
- }
-
- textContainer.containerSize = targetSize
- if storage.length > 0 {
- let fullRange = NSRange(location: 0, length: storage.length)
- layoutManager.invalidateLayout(forCharacterRange: fullRange, actualCharacterRange: nil)
- layoutManager.invalidateDisplay(forCharacterRange: fullRange)
- }
- documentContainerView.invalidateIntrinsicContentSize()
- return true
- }
-
- private func measuredTextHeight() -> CGFloat {
- layoutManager.ensureLayout(for: textContainer)
- let usedRect = layoutManager.usedRect(for: textContainer)
- return ceil(usedRect.height + textView.textContainerInset.height * 2)
- }
-
- private var effectiveScrollContentSize: NSSize {
- let contentSize = scrollView.contentSize
- let contentInsets = scrollView.contentView.contentInsets
- return NSSize(
- width: max(0, contentSize.width - max(0, contentInsets.left) - max(0, contentInsets.right)),
- height: max(0, contentSize.height - max(0, contentInsets.bottom))
- )
- }
-
- private func syncDocumentFrameToTextLayout() {
- let contentSize = effectiveScrollContentSize
- syncTextContainerWidthToTextView()
- let targetFrame = NSRect(
- x: 0,
- y: 0,
- width: contentSize.width,
- height: measuredTextHeight()
- )
- if rectsAreNearlyEqual(documentContainerView.frame, targetFrame) == false {
- documentContainerView.frame = targetFrame
- }
- if rectsAreNearlyEqual(textView.frame, documentContainerView.bounds) == false {
- textView.frame = documentContainerView.bounds
- }
- syncThreadBackgroundViews()
- }
-
- private func syncThreadBackgroundViews() {
- let rects = textView.threadBackgroundRects()
- while threadBackgroundViews.count < rects.count {
- let backgroundView = makeThreadBackgroundView()
- documentContainerView.addSubview(backgroundView, positioned: .below, relativeTo: textView)
- threadBackgroundViews.append(backgroundView)
- }
- while threadBackgroundViews.count > rects.count {
- threadBackgroundViews.removeLast().removeFromSuperview()
- }
- for (backgroundView, rect) in zip(threadBackgroundViews, rects) {
- if rectsAreNearlyEqual(backgroundView.frame, rect) == false {
- backgroundView.frame = rect
- }
- }
- syncThreadBackgroundAppearance()
- }
-
- private func makeThreadBackgroundView() -> NSVisualEffectView {
- let backgroundView = NSVisualEffectView()
- backgroundView.material = .contentBackground
- backgroundView.blendingMode = .withinWindow
- backgroundView.alphaValue = threadBackgroundAlpha
- backgroundView.state = .active
- backgroundView.wantsLayer = true
- backgroundView.layer?.cornerRadius = Layout.threadBackgroundCornerRadius
- backgroundView.layer?.masksToBounds = true
- return backgroundView
- }
-
- private var threadBackgroundAlpha: CGFloat {
- let workspace = NSWorkspace.shared
- if workspace.accessibilityDisplayShouldReduceTransparency ||
- workspace.accessibilityDisplayShouldIncreaseContrast {
- return 1
- }
- return 0.3
- }
-
- private func observeAccessibilityDisplayOptions() {
- NSWorkspace.shared.notificationCenter.addObserver(
- self,
- selector: #selector(accessibilityDisplayOptionsDidChange(_:)),
- name: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification,
- object: nil
- )
- }
-
- @objc private func accessibilityDisplayOptionsDidChange(_ notification: Notification) {
- syncThreadBackgroundAppearance()
- }
-
- private func syncThreadBackgroundAppearance() {
- let alpha = threadBackgroundAlpha
- for backgroundView in threadBackgroundViews {
- backgroundView.alphaValue = alpha
- }
- }
-
- private func rectsAreNearlyEqual(_ lhs: NSRect, _ rhs: NSRect) -> Bool {
- abs(lhs.minX - rhs.minX) <= 0.5 &&
- abs(lhs.minY - rhs.minY) <= 0.5 &&
- abs(lhs.width - rhs.width) <= 0.5 &&
- abs(lhs.height - rhs.height) <= 0.5
- }
-
- private func scrollToTopRespectingContentInsets() {
- let topOrigin = NSPoint(
- x: 0,
- y: -max(0, scrollView.contentView.contentInsets.top)
- )
- scrollView.contentView.scroll(to: topOrigin)
- scrollView.reflectScrolledClipView(scrollView.contentView)
- }
-
- private func minimumVerticalScrollOffset() -> CGFloat {
- -max(0, scrollView.contentView.contentInsets.top)
- }
-
- private func maximumVerticalScrollOffset() -> CGFloat {
- let minY = minimumVerticalScrollOffset()
- let bottomInset = max(0, scrollView.contentView.contentInsets.bottom)
- let maxY = documentContainerView.frame.height - scrollView.contentView.bounds.height + bottomInset
- return max(minY, maxY)
- }
-
- private func makeRenderedText(entries: [Entry]) -> RenderedText {
- let result = NSMutableAttributedString()
- var threadRanges: [NSRange] = []
- var currentThreadID: String?
- var currentThreadLocation: Int?
-
- func closeCurrentThreadRange() {
- guard let location = currentThreadLocation else {
- return
- }
- let length = result.length - location
- if length > 0 {
- threadRanges.append(NSRange(location: location, length: length))
- }
- }
-
- for index in entries.indices {
- let entry = entries[index]
- if currentThreadID == nil {
- appendSectionTitle(for: entry, to: result)
- currentThreadID = entry.threadID
- currentThreadLocation = result.length
- } else if currentThreadID == entry.threadID {
- appendParagraphSeparator(
- to: result,
- bodyParagraphSpacingAfter: spacingAfterEntry(at: index - 1, in: entries)
- )
- } else {
- closeCurrentThreadRange()
- appendParagraphSeparator(
- to: result,
- bodyParagraphSpacingAfter: spacingAfterEntry(at: index - 1, in: entries)
- )
- appendSectionTitle(for: entry, to: result)
- currentThreadID = entry.threadID
- currentThreadLocation = result.length
- }
- appendEntry(
- entry,
- to: result,
- bodyParagraphSpacingAfter: spacingAfterEntry(at: index, in: entries)
- )
- }
- closeCurrentThreadRange()
- return RenderedText(attributedString: result, threadRanges: threadRanges)
- }
-
- private func spacingAfterEntry(at index: Int, in entries: [Entry]) -> CGFloat {
- let nextIndex = index + 1
- guard entries.indices.contains(nextIndex) else {
- return 0
- }
- return entries[nextIndex].threadID == entries[index].threadID
- ? Layout.findingSpacing
- : Layout.threadSpacing
- }
-
- private func appendSectionTitle(
- for entry: Entry,
- to result: NSMutableAttributedString
- ) {
- guard let sectionTitle = entry.sectionTitle else {
- return
- }
- appendTrimmed(sectionTitle, to: result, attributes: sectionTitleAttributes)
- appendSectionTitleSeparator(to: result)
- }
-
- private func appendEntry(
- _ entry: Entry,
- to result: NSMutableAttributedString,
- bodyParagraphSpacingAfter: CGFloat
- ) {
- if let priorityText = entry.priorityText {
- result.append(NSAttributedString(
- string: "[\(priorityText)]",
- attributes: priorityTextAttributes(for: priorityText)
- ))
- result.append(NSAttributedString(string: " ", attributes: titleAttributes))
- }
- appendTrimmed(entry.titleText, to: result, attributes: titleAttributes)
- appendNewline(to: result)
- if let locationText = entry.locationText?.trimmingCharacters(in: .whitespacesAndNewlines),
- locationText.isEmpty == false {
- appendTrimmed(
- entry.body,
- to: result,
- attributes: bodyAttributes(paragraphSpacingAfter: Layout.titleBodySpacing)
- )
- appendMetadataSeparator(to: result)
- appendTrimmed(
- locationText,
- to: result,
- attributes: metadataAttributes(paragraphSpacingAfter: bodyParagraphSpacingAfter)
- )
- } else {
- appendTrimmed(
- entry.body,
- to: result,
- attributes: bodyAttributes(paragraphSpacingAfter: bodyParagraphSpacingAfter)
- )
- }
- }
-
- private func appendTrimmed(
- _ string: String,
- to result: NSMutableAttributedString,
- attributes: [NSAttributedString.Key: Any]
- ) {
- let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
- guard trimmed.isEmpty == false else {
- return
- }
- result.append(NSAttributedString(string: trimmed, attributes: attributes))
- }
-
- private func appendNewline(to result: NSMutableAttributedString) {
- guard result.string.hasSuffix("\n") == false else {
- return
- }
- result.append(NSAttributedString(string: "\n", attributes: titleAttributes))
- }
-
- private func appendParagraphSeparator(
- to result: NSMutableAttributedString,
- bodyParagraphSpacingAfter: CGFloat
- ) {
- result.append(NSAttributedString(
- string: "\n",
- attributes: bodyAttributes(paragraphSpacingAfter: bodyParagraphSpacingAfter)
- ))
- }
-
- private func appendMetadataSeparator(to result: NSMutableAttributedString) {
- result.append(NSAttributedString(
- string: "\n",
- attributes: bodyAttributes(paragraphSpacingAfter: Layout.titleBodySpacing)
- ))
- }
-
- private func appendSectionTitleSeparator(to result: NSMutableAttributedString) {
- result.append(NSAttributedString(string: "\n", attributes: sectionTitleAttributes))
- }
-
- private var sectionTitleAttributes: [NSAttributedString.Key: Any] {
- [
- .font: sectionTitleFont,
- .foregroundColor: NSColor.labelColor,
- .paragraphStyle: paragraphStyle(paragraphSpacingAfter: Layout.sectionTitleSpacing),
- ]
- }
-
- private var titleAttributes: [NSAttributedString.Key: Any] {
- [
- .font: titleFont,
- .foregroundColor: NSColor.labelColor,
- .paragraphStyle: paragraphStyle(paragraphSpacingAfter: Layout.titleBodySpacing),
- ]
- }
-
- private func priorityTextAttributes(for text: String) -> [NSAttributedString.Key: Any] {
- var attributes = titleAttributes
- attributes[.foregroundColor] = priorityTextColor(for: text)
- return attributes
- }
-
- private func priorityTextColor(for text: String) -> NSColor {
- let color: NSColor
- switch text {
- case "P0":
- color = .systemRed
- case "P1":
- color = .systemOrange
- case "P2":
- color = .systemYellow
- case "P3":
- return .systemGray
- default:
- return .systemGray
- }
- return tonedPriorityTextColor(color)
- }
-
- private func tonedPriorityTextColor(_ color: NSColor) -> NSColor {
- let workspace = NSWorkspace.shared
- guard workspace.accessibilityDisplayShouldIncreaseContrast == false,
- workspace.accessibilityDisplayShouldReduceTransparency == false
- else {
- return color
- }
- return color.withAlphaComponent(0.8)
- }
-
- private var titleFont: NSFont {
- NSFont.preferredFont(forTextStyle: .headline)
- }
-
- private var sectionTitleFont: NSFont {
- NSFont.preferredFont(forTextStyle: .subheadline)
- }
-
- private var bodyFont: NSFont {
- NSFont.preferredFont(forTextStyle: .subheadline)
- }
-
- private var metadataFont: NSFont {
- NSFont.preferredFont(forTextStyle: .caption1)
- }
-
- private func bodyAttributes(
- paragraphSpacingAfter: CGFloat = 0
- ) -> [NSAttributedString.Key: Any] {
- [
- .font: bodyFont,
- .foregroundColor: NSColor.labelColor,
- .paragraphStyle: paragraphStyle(
- lineSpacing: Layout.bodyLineSpacing,
- paragraphSpacingAfter: paragraphSpacingAfter
- ),
- ]
- }
-
- private func metadataAttributes(
- paragraphSpacingAfter: CGFloat
- ) -> [NSAttributedString.Key: Any] {
- [
- .font: metadataFont,
- .foregroundColor: NSColor.secondaryLabelColor,
- .paragraphStyle: paragraphStyle(paragraphSpacingAfter: paragraphSpacingAfter),
- ]
- }
-
- private func paragraphStyle(
- lineSpacing: CGFloat = 0,
- paragraphSpacingAfter: CGFloat = 0
- ) -> NSParagraphStyle {
- let style = NSMutableParagraphStyle()
- style.alignment = .left
- style.lineBreakMode = .byWordWrapping
- style.lineSpacing = lineSpacing
- style.paragraphSpacing = paragraphSpacingAfter
- return style
- }
-
- @discardableResult
- func performDisplayedTextFinderAction(_ sender: Any?) -> Bool {
- guard scrollView.isHidden == false else {
- return false
- }
- textView.performTextFinderAction(sender)
- return true
- }
-
- func validateDisplayedTextFinderAction(_ item: NSValidatedUserInterfaceItem) -> Bool {
- scrollView.isHidden == false && textView.validateUserInterfaceItem(item)
- }
-}
-
-#if DEBUG
-@MainActor
-extension ReviewMonitorWorkspaceFindingsView {
- var displayedTextForTesting: String {
- displayedText
- }
-
- var accessibilityValueForTesting: String? {
- textView.accessibilityValue()
- }
-
- var renderedStorageStringForTesting: String {
- storage.string
- }
-
- var isShowingFindingsListForTesting: Bool {
- scrollView.isHidden == false
- }
-
- var contentWidthForTesting: CGFloat {
- layoutSubtreeIfNeeded()
- return scrollView.contentView.bounds.width
- }
-
- var scrollFrameForTesting: NSRect {
- scrollView.frame
- }
-
- var contentInsetsForTesting: NSEdgeInsets {
- scrollView.contentView.contentInsets
- }
-
- var verticalScrollOffsetForTesting: CGFloat {
- scrollView.contentView.bounds.origin.y
- }
-
- var minimumVerticalScrollOffsetForTesting: CGFloat {
- minimumVerticalScrollOffset()
- }
-
- var maximumVerticalScrollOffsetForTesting: CGFloat {
- maximumVerticalScrollOffset()
- }
-
- var documentFrameForTesting: NSRect {
- documentContainerView.frame
- }
-
- var automaticallyAdjustsContentInsetsForTesting: Bool {
- scrollView.automaticallyAdjustsContentInsets
- }
-
- var textContainerWidthForTesting: CGFloat {
- layoutSubtreeIfNeeded()
- syncTextContainerWidthToTextView()
- return textContainer.containerSize.width
- }
-
- var isTextSelectableForTesting: Bool {
- textView.isSelectable
- }
-
- var isTextEditableForTesting: Bool {
- textView.isEditable
- }
-
- var usesFindBarForTesting: Bool {
- textView.usesFindBar
- }
-
- var isIncrementalSearchingEnabledForTesting: Bool {
- textView.isIncrementalSearchingEnabled
- }
-
- var isFindBarVisibleForTesting: Bool {
- scrollView.isFindBarVisible
- }
-
- var priorityPrefixCountForTesting: Int {
- (0...3).reduce(0) { count, priority in
- count + displayedText.components(separatedBy: "[P\(priority)]").count - 1
- }
- }
-
- var textAttachmentCountForTesting: Int {
- var count = 0
- storage.enumerateAttribute(
- .attachment,
- in: NSRange(location: 0, length: storage.length)
- ) { value, _, _ in
- if value is NSTextAttachment {
- count += 1
- }
- }
- return count
- }
-
- var threadBackgroundRangeCountForTesting: Int {
- textView.threadCharacterRanges.count
- }
-}
-#endif
-
-#if DEBUG
-#Preview("Workspace Findings") {
- ReviewMonitorWorkspaceFindingsPreviewHost(entries: makeWorkspaceFindingsPreviewEntries())
- .frame(maxWidth: .infinity, maxHeight: .infinity)
-}
-
-#Preview("No Findings") {
- ReviewMonitorWorkspaceFindingsPreviewHost(entries: [])
- .frame(maxWidth: .infinity, maxHeight: .infinity)
-}
-
-@MainActor
-private struct ReviewMonitorWorkspaceFindingsPreviewHost: NSViewRepresentable {
- let entries: [ReviewMonitorWorkspaceFindingsView.Entry]
-
- func makeNSView(context: Context) -> ReviewMonitorWorkspaceFindingsView {
- let view = ReviewMonitorWorkspaceFindingsView(frame: .zero)
- view.render(entries: entries)
- return view
- }
-
- func updateNSView(
- _ nsView: ReviewMonitorWorkspaceFindingsView,
- context: Context
- ) {
- nsView.render(entries: entries)
- }
-}
-
-@MainActor
-private func makeWorkspaceFindingsPreviewEntries() -> [ReviewMonitorWorkspaceFindingsView.Entry] {
- [
- .init(
- threadID: "thread-undo",
- targetSummary: "Uncommitted changes",
- priority: 0,
- title: "[P0] Stop stale undo commands from escaping cleared history",
- body: "Cancel every queued undo operation before clearing history so stale DOM.undo and DOM.redo commands cannot run after detach.",
- locationText: "Sources/WebInspectorRuntime/InspectorSession.swift:1192-1192"
- ),
- .init(
- threadID: "thread-undo",
- targetSummary: "Uncommitted changes",
- priority: 1,
- title: "[P1] Cancel the queued operation chain, not only the tail",
- body: "When multiple undo operations are waiting on each other, clearing the latest task is not enough. Track and cancel every queued operation owned by the thread."
- ),
- .init(
- threadID: "thread-selection",
- targetSummary: "Branch: feature/workspace-alpha-sidebar",
- priority: 1,
- title: "[P1] Preserve the selected workspace across reload",
- body: "The sidebar reload currently drops the workspace selection before the detail view can refresh. Resolve the selected workspace by cwd before clearing selection.",
- locationText: "Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift:184-203"
- ),
- .init(
- threadID: "thread-selection",
- targetSummary: "Branch: feature/workspace-alpha-sidebar",
- priority: 2,
- title: "[P2] Refresh workspace findings after job result changes",
- body: "The workspace detail should observe each displayed job's structured review result and redraw only when the aggregated finding text changes."
- ),
- .init(
- threadID: "thread-contract",
- targetSummary: "Base branch: main",
- priority: 2,
- title: "[P2] Keep workspace findings scoped to structured results",
- body: "The detail pane should keep using ParsedReviewResult.findings only. Re-parsing log text here would reintroduce mismatches with the MCP result contract.",
- locationText: "Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift:223-246"
- ),
- .init(
- threadID: "thread-contract",
- targetSummary: "Base branch: main",
- priority: 0,
- title: "[P0] Do not parse transport logs for review findings",
- body: "The UI must not recover findings by scanning log text. The structured ParsedReviewResult.findings payload is the source of truth for this pane."
- ),
- .init(
- threadID: "thread-polish",
- targetSummary: "Commit: abc1234",
- priority: 3,
- title: "[P3] Avoid clipping long paths in metadata",
- body: "Long file paths need middle truncation so row titles and descriptions remain readable in narrow detail panes."
- ),
- .init(
- threadID: "thread-polish",
- targetSummary: "Commit: abc1234",
- priority: 3,
- title: "[P3] Preserve no-findings empty state alignment",
- body: "A workspace with only unknown or clean review results should continue to show the centered empty state instead of reserving space for an empty text document."
- ),
- ]
-}
-#endif
diff --git a/Sources/ReviewUI/MCPServerUnavailableView.swift b/Sources/ReviewUI/MCPServerUnavailableView.swift
index a913438c..9d882a85 100644
--- a/Sources/ReviewUI/MCPServerUnavailableView.swift
+++ b/Sources/ReviewUI/MCPServerUnavailableView.swift
@@ -6,7 +6,7 @@
//
import SwiftUI
-import CodexReview
+import CodexReviewKit
struct MCPServerUnavailableView: View {
let store: CodexReviewStore
diff --git a/Sources/ReviewUI/PlaceholderViewController.swift b/Sources/ReviewUI/PlaceholderViewController.swift
index d769d670..73df408b 100644
--- a/Sources/ReviewUI/PlaceholderViewController.swift
+++ b/Sources/ReviewUI/PlaceholderViewController.swift
@@ -4,7 +4,7 @@ import SwiftUI
enum PlaceholderContent: Equatable {
case noSelection
case noFindings
- case noReviewJobs
+ case noReviewChats
var title: LocalizedStringResource {
switch self {
@@ -12,8 +12,8 @@ enum PlaceholderContent: Equatable {
"Select a workspace or review"
case .noFindings:
"No findings"
- case .noReviewJobs:
- "No review jobs"
+ case .noReviewChats:
+ "No review chats"
}
}
@@ -23,8 +23,8 @@ enum PlaceholderContent: Equatable {
"Choose a workspace or review from the list."
case .noFindings:
"No structured review findings are available for this workspace."
- case .noReviewJobs:
- "Start a review through the embedded server to see workspaces here."
+ case .noReviewChats:
+ "Start a review through the embedded server to see review chats here."
}
}
@@ -34,7 +34,7 @@ enum PlaceholderContent: Equatable {
"review-monitor.detail-empty.title"
case .noFindings:
"review-monitor.workspace-findings-empty.title"
- case .noReviewJobs:
+ case .noReviewChats:
"review-monitor.sidebar-empty.title"
}
}
@@ -45,7 +45,7 @@ enum PlaceholderContent: Equatable {
"review-monitor.detail-empty.description"
case .noFindings:
"review-monitor.workspace-findings-empty.description"
- case .noReviewJobs:
+ case .noReviewChats:
"review-monitor.sidebar-empty.description"
}
}
diff --git a/Sources/ReviewUI/ReviewMonitorAddAccountAction.swift b/Sources/ReviewUI/ReviewMonitorAddAccountAction.swift
index 55c989ab..9aa1463b 100644
--- a/Sources/ReviewUI/ReviewMonitorAddAccountAction.swift
+++ b/Sources/ReviewUI/ReviewMonitorAddAccountAction.swift
@@ -1,5 +1,5 @@
import AppKit
-import CodexReview
+import CodexReviewKit
@MainActor
enum ReviewMonitorAddAccountAction {
diff --git a/Sources/ReviewUI/ReviewMonitorAddAccountToolbarItem.swift b/Sources/ReviewUI/ReviewMonitorAddAccountToolbarItem.swift
index d0ec5e25..cc4328f5 100644
--- a/Sources/ReviewUI/ReviewMonitorAddAccountToolbarItem.swift
+++ b/Sources/ReviewUI/ReviewMonitorAddAccountToolbarItem.swift
@@ -1,6 +1,6 @@
import AppKit
import ObservationBridge
-import CodexReview
+import CodexReviewKit
@MainActor
final class ReviewMonitorAddAccountToolbarItem: NSToolbarItem {
diff --git a/Sources/ReviewUI/ReviewMonitorCodexModelSource.swift b/Sources/ReviewUI/ReviewMonitorCodexModelSource.swift
new file mode 100644
index 00000000..58fc3a67
--- /dev/null
+++ b/Sources/ReviewUI/ReviewMonitorCodexModelSource.swift
@@ -0,0 +1,25 @@
+import CodexKit
+import Observation
+
+@MainActor
+@Observable
+public final class ReviewMonitorCodexModelSource {
+ public private(set) var modelContext: CodexModelContext?
+
+ @ObservationIgnored
+ private var container: CodexModelContainer?
+
+ public init(modelContext: CodexModelContext? = nil) {
+ self.modelContext = modelContext
+ }
+
+ public func install(container: CodexModelContainer) {
+ self.container = container
+ modelContext = container.mainContext
+ }
+
+ public func clear() {
+ container = nil
+ modelContext = nil
+ }
+}
diff --git a/Sources/ReviewUI/ReviewMonitorCodexSelectionTitleResolver.swift b/Sources/ReviewUI/ReviewMonitorCodexSelectionTitleResolver.swift
new file mode 100644
index 00000000..3a975601
--- /dev/null
+++ b/Sources/ReviewUI/ReviewMonitorCodexSelectionTitleResolver.swift
@@ -0,0 +1,67 @@
+import CodexKit
+
+@MainActor
+struct ReviewMonitorCodexSelectionTitlePresentation: Equatable, Sendable {
+ var title: String
+ var subtitle: String
+}
+
+@MainActor
+final class ReviewMonitorCodexSelectionTitleResolver {
+ private let modelContext: CodexModelContext
+
+ init(modelContext: CodexModelContext) {
+ self.modelContext = modelContext
+ }
+
+ func titlePresentation(
+ for selection: ReviewMonitorSelection?
+ ) -> ReviewMonitorCodexSelectionTitlePresentation? {
+ switch selection {
+ case .workspaceGroup(let id):
+ guard let workspaceGroup = modelContext.model(for: id) else {
+ return nil
+ }
+ return Self.titlePresentation(for: workspaceGroup)
+
+ case .chat(let id):
+ guard let chat = loadedChat(id: id) else {
+ return nil
+ }
+ return Self.titlePresentation(for: chat)
+
+ case nil:
+ return nil
+ }
+ }
+
+ private func loadedChat(id: CodexThreadID) -> CodexChat? {
+ modelContext.registeredModel(for: id)
+ }
+
+ private static func titlePresentation(
+ for workspaceGroup: CodexWorkspaceGroup
+ ) -> ReviewMonitorCodexSelectionTitlePresentation? {
+ let workspacePaths = workspaceGroup.workspaces.map(\.url.path)
+ guard workspacePaths.isEmpty == false else {
+ return nil
+ }
+ let subtitle =
+ workspacePaths.count == 1
+ ? (workspacePaths.first ?? "")
+ : "\(workspacePaths.count) workspaces"
+ return ReviewMonitorCodexSelectionTitlePresentation(
+ title: workspaceGroup.name,
+ subtitle: subtitle
+ )
+ }
+
+ private static func titlePresentation(
+ for chat: CodexChat
+ ) -> ReviewMonitorCodexSelectionTitlePresentation {
+ ReviewMonitorCodexSelectionTitlePresentation(
+ title: chat.title,
+ subtitle: chat.workspace?.url.path ?? ""
+ )
+ }
+}
diff --git a/Sources/ReviewUI/ReviewMonitorContentKind.swift b/Sources/ReviewUI/ReviewMonitorContentKind.swift
new file mode 100644
index 00000000..319683d3
--- /dev/null
+++ b/Sources/ReviewUI/ReviewMonitorContentKind.swift
@@ -0,0 +1,4 @@
+enum ReviewMonitorContentKind: Equatable, CaseIterable {
+ case contentView
+ case signInView
+}
diff --git a/Sources/ReviewUI/ReviewMonitorContentView.swift b/Sources/ReviewUI/ReviewMonitorContentView.swift
index 43caa773..a7602512 100644
--- a/Sources/ReviewUI/ReviewMonitorContentView.swift
+++ b/Sources/ReviewUI/ReviewMonitorContentView.swift
@@ -1,7 +1,8 @@
import AppKit
import Combine
+import CodexKit
import ObservationBridge
-import CodexReview
+import CodexReviewKit
typealias ReviewMonitorContentTransitionAnimator = @MainActor (
NSView,
@@ -10,11 +11,13 @@ typealias ReviewMonitorContentTransitionAnimator = @MainActor (
) -> Void
@MainActor
-final class ReviewMonitorRootViewController: NSViewController {
+package final class ReviewMonitorRootViewController: NSViewController {
private let uiState: ReviewMonitorUIState
private let store: CodexReviewStore
+ private let codexModelSource: ReviewMonitorCodexModelSource?
private let contentTransitionAnimator: ReviewMonitorContentTransitionAnimator
private let showSettings: (@MainActor () -> Void)?
+ private let dependencyRetainer: AnyObject?
private var observation: PortableObservationTracking.Token?
private var windowCancellable: AnyCancellable?
private var presentedContentKind: ReviewMonitorContentKind?
@@ -22,21 +25,60 @@ final class ReviewMonitorRootViewController: NSViewController {
private lazy var splitViewController = ReviewMonitorSplitViewController(
store: store,
uiState: uiState,
+ codexModelSource: codexModelSource,
showSettings: showSettings
)
private lazy var signInViewController = ReviewMonitorSignInViewController(store: store)
- init(
+ convenience init(
store: CodexReviewStore,
uiState: ReviewMonitorUIState,
+ modelContext: CodexModelContext,
contentTransitionAnimator: @escaping ReviewMonitorContentTransitionAnimator = ReviewMonitorRootViewController.defaultContentTransitionAnimator,
showSettings: (@MainActor () -> Void)? = nil
+ ) {
+ self.init(
+ store: store,
+ uiState: uiState,
+ codexModelSource: ReviewMonitorCodexModelSource(modelContext: modelContext),
+ contentTransitionAnimator: contentTransitionAnimator,
+ showSettings: showSettings,
+ dependencyRetainer: nil
+ )
+ }
+
+ package convenience init(
+ store: CodexReviewStore,
+ uiState: ReviewMonitorUIState,
+ codexModelSource: ReviewMonitorCodexModelSource? = nil,
+ showSettings: (@MainActor () -> Void)? = nil,
+ dependencyRetainer: AnyObject? = nil
+ ) {
+ self.init(
+ store: store,
+ uiState: uiState,
+ codexModelSource: codexModelSource,
+ contentTransitionAnimator: Self.defaultContentTransitionAnimator,
+ showSettings: showSettings,
+ dependencyRetainer: dependencyRetainer
+ )
+ }
+
+ init(
+ store: CodexReviewStore,
+ uiState: ReviewMonitorUIState,
+ codexModelSource: ReviewMonitorCodexModelSource? = nil,
+ contentTransitionAnimator: @escaping ReviewMonitorContentTransitionAnimator = ReviewMonitorRootViewController.defaultContentTransitionAnimator,
+ showSettings: (@MainActor () -> Void)? = nil,
+ dependencyRetainer: AnyObject? = nil
) {
self.store = store
self.uiState = uiState
+ self.codexModelSource = codexModelSource
self.contentTransitionAnimator = contentTransitionAnimator
self.showSettings = showSettings
+ self.dependencyRetainer = dependencyRetainer
super.init(nibName: nil, bundle: nil)
}
@@ -49,7 +91,7 @@ final class ReviewMonitorRootViewController: NSViewController {
observation?.cancel()
}
- override func loadView() {
+ package override func loadView() {
let backgroundView = NSVisualEffectView()
backgroundView.material = .underWindowBackground
backgroundView.blendingMode = .behindWindow
@@ -57,7 +99,7 @@ final class ReviewMonitorRootViewController: NSViewController {
view = backgroundView
}
- override func viewDidLoad() {
+ package override func viewDidLoad() {
super.viewDidLoad()
bindWindowState()
bindWindowAttachment()
@@ -222,6 +264,15 @@ final class ReviewMonitorRootViewController: NSViewController {
}
}
+@MainActor
+extension ReviewMonitorRootViewController {
+ package func prepareForImmediateRenderingForPreviewSupport() {
+ loadViewIfNeeded()
+ splitViewController.prepareForImmediateRenderingForPreviewSupport()
+ view.layoutSubtreeIfNeeded()
+ }
+}
+
#if DEBUG
@MainActor
extension ReviewMonitorRootViewController {
@@ -253,41 +304,4 @@ extension ReviewMonitorRootViewController {
view.subviews.count
}
}
-
-@MainActor
-func makeReviewMonitorPreviewContentViewController() -> NSViewController {
- makeReviewMonitorPreviewContentViewControllerForPreview()
-}
-
-@MainActor
-func makeReviewMonitorPreviewContentViewControllerForPreview(
- authPhase: CodexReviewAuthModel.Phase = .signedOut,
- account: CodexAccount? = nil,
- serverState: CodexReviewServerState = .running,
- previewStore: CodexReviewStore? = nil
-) -> ReviewMonitorRootViewController {
- let store: CodexReviewStore
- switch serverState {
- case .running:
- store = previewStore ?? ReviewMonitorPreviewContent.makeStore()
- case .failed, .starting, .stopped:
- store = CodexReviewStore.makePreviewStore()
- store.serverState = serverState
- store.serverURL = nil
- }
- let previewAccounts = ReviewMonitorPreviewContent.makePreviewAccounts()
- let resolvedAccount = account ?? previewAccounts.first
- store.auth.updatePhase(authPhase)
- store.auth.applyPersistedAccountStates(previewAccounts.map(savedAccountPayload(from:)))
- store.auth.selectPersistedAccount(resolvedAccount?.id)
- let uiState = ReviewMonitorUIState(auth: store.auth)
- if case .running = serverState,
- let previewJob = store.orderedJobs
- .first(where: { $0.core.lifecycle.status == .running })
- ?? store.orderedJobs.first
- {
- uiState.selection = .job(previewJob)
- }
- return ReviewMonitorRootViewController(store: store, uiState: uiState)
-}
#endif
diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift
deleted file mode 100644
index 059cbad1..00000000
--- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift
+++ /dev/null
@@ -1,964 +0,0 @@
-import Foundation
-@_spi(Testing) import CodexReview
-
-@_spi(PreviewSupport)
-@MainActor
-public enum ReviewMonitorPreviewContent {
- private struct PreviewJobDefinition {
- let status: ReviewJobState
- let targetSummary: String
- let summary: String
- let lastAgentMessage: String
- let model: String
- let startedOffset: TimeInterval?
- let endedOffset: TimeInterval?
- let hasFinalReview: Bool
- }
-
- private struct PreviewStreamTemplate {
- let kind: ReviewLogEntry.Kind
- let groupName: String?
- let text: String
- let metadata: ReviewLogEntry.Metadata?
- let replacesGroup: Bool
- let chunkByWord: Bool
- let delayBeforeFrameCount: Int
- let chunkIntervalFrameCount: Int
-
- init(
- kind: ReviewLogEntry.Kind,
- groupName: String? = nil,
- text: String,
- metadata: ReviewLogEntry.Metadata? = nil,
- replacesGroup: Bool = false,
- chunkByWord: Bool = false,
- delayBeforeFrameCount: Int,
- chunkIntervalFrameCount: Int = 1
- ) {
- self.kind = kind
- self.groupName = groupName
- self.text = text
- self.metadata = metadata
- self.replacesGroup = replacesGroup
- self.chunkByWord = chunkByWord
- self.delayBeforeFrameCount = delayBeforeFrameCount
- self.chunkIntervalFrameCount = chunkIntervalFrameCount
- }
- }
-
- private struct PreviewStreamStep {
- let kind: ReviewLogEntry.Kind
- let groupName: String?
- let text: String
- let metadata: ReviewLogEntry.Metadata?
- let replacesGroup: Bool
- }
-
- private struct PreviewStreamFrame {
- let step: PreviewStreamStep
- let cycle: Int
- }
-
- @MainActor
- private final class PreviewLogStreamer {
- private weak var store: CodexReviewStore?
- private let interval: Duration
- private var task: Task?
- private var tick = 0
-
- init(store: CodexReviewStore, interval: Duration) {
- self.store = store
- self.interval = interval
- task = Task { [weak self, interval] in
- while Task.isCancelled == false {
- try? await Task.sleep(for: interval)
- guard let self, Task.isCancelled == false else {
- return
- }
- self.emitTick()
- }
- }
- }
-
- deinit {
- task?.cancel()
- }
-
- private func emitTick() {
- guard let store else {
- task?.cancel()
- return
- }
-
- tick = ReviewMonitorPreviewContent.appendPreviewStreamTick(
- to: store,
- after: tick
- )
- }
- }
-
- @_spi(PreviewSupport)
- public static func makeStore(
- streamInterval: Duration? = .milliseconds(40)
- ) -> CodexReviewStore {
- let store = CodexReviewStore.makePreviewStore(
- seed: .init(initialSettingsSnapshot: makePreviewSettingsSnapshot())
- )
- let accounts = makePreviewAccounts()
- let previewContent = makeSidebarContent()
- store.loadForTesting(
- serverState: .running,
- account: accounts.first,
- persistedAccounts: accounts,
- serverURL: URL(string: "http://localhost:9417/mcp"),
- workspaces: previewContent.workspaces,
- jobs: previewContent.jobs
- )
- if let streamInterval {
- store.previewSupportRetainer = PreviewLogStreamer(
- store: store,
- interval: streamInterval
- )
- }
- return store
- }
-
- @_spi(PreviewSupport)
- public static func makeCommandOutputStore() -> CodexReviewStore {
- let store = CodexReviewStore.makePreviewStore(
- seed: .init(initialSettingsSnapshot: makePreviewSettingsSnapshot())
- )
- let accounts = makePreviewAccounts()
- let cwd = "/path/to/workspace-alpha"
- let now = Date()
- let job = makeJob(
- id: "preview-command-output-panel",
- cwd: cwd,
- model: "gpt-5.4",
- status: .running,
- targetSummary: "Command output panel",
- startedAt: now.addingTimeInterval(-135),
- endedAt: nil,
- summary: "A command output block is collapsed by default.",
- hasFinalReview: false,
- reviewResult: nil,
- lastAgentMessage: "Opened command output should stay bounded to a short embedded scroll view.",
- logEntries: makeCommandOutputPreviewLogEntries()
- )
- store.loadForTesting(
- serverState: .running,
- account: accounts.first,
- persistedAccounts: accounts,
- serverURL: URL(string: "http://localhost:9417/mcp"),
- workspaces: [CodexReviewWorkspace(cwd: cwd)],
- jobs: [job]
- )
- return store
- }
-
- @_spi(PreviewSupport)
- public static func appendPreviewStreamTick(to store: CodexReviewStore) {
- _ = appendPreviewStreamTick(to: store, after: 0)
- }
-
- @discardableResult
- package static func appendPreviewStreamTick(
- to store: CodexReviewStore,
- after currentTick: Int
- ) -> Int {
- let runningJobs = store.orderedJobs
- .filter { $0.core.lifecycle.status == .running }
-
- guard runningJobs.isEmpty == false else {
- return currentTick
- }
-
- let nextTick = currentTick + 1
- for (index, job) in runningJobs.enumerated() {
- if let frame = streamFrame(forJobAt: index, tick: nextTick) {
- job.appendLogEntry(streamEntry(from: frame.step, for: job, cycle: frame.cycle))
- }
- }
- return nextTick
- }
-
- private static func streamFrame(
- forJobAt jobIndex: Int,
- tick: Int
- ) -> PreviewStreamFrame? {
- guard previewStreamTimeline.isEmpty == false else {
- return nil
- }
- let jobTick = tick - jobIndex * previewJobTickOffset
- guard jobTick > 0 else {
- return nil
- }
-
- let offset = (jobTick - 1) % previewStreamTimeline.count
- guard let step = previewStreamTimeline[offset] else {
- return nil
- }
- return PreviewStreamFrame(
- step: step,
- cycle: (jobTick - 1) / previewStreamTimeline.count
- )
- }
-
- private static func streamEntry(
- from step: PreviewStreamStep,
- for job: CodexReviewJob,
- cycle: Int
- ) -> ReviewLogEntry {
- let groupID = step.groupName.map { "preview-\($0)-\(job.id)-\(cycle)" }
- return .init(
- kind: step.kind,
- groupID: groupID,
- replacesGroup: step.replacesGroup,
- text: step.text,
- metadata: streamMetadata(from: step, groupID: groupID)
- )
- }
-
- private static func streamMetadata(
- from step: PreviewStreamStep,
- groupID: String?
- ) -> ReviewLogEntry.Metadata? {
- switch step.kind {
- case .command:
- let command = commandText(from: step.text)
- let startedAt = Date()
- return .init(
- sourceType: "commandExecution",
- status: "inProgress",
- itemID: groupID,
- command: command,
- startedAt: startedAt,
- commandStatus: "inProgress"
- )
- case .commandOutput:
- guard let metadata = step.metadata else {
- return nil
- }
- return commandOutputCompletionMetadata(
- from: metadata,
- groupID: groupID,
- completedAt: Date()
- )
- case .agentMessage, .plan, .todoList, .reasoning, .reasoningSummary, .rawReasoning,
- .toolCall, .diagnostic, .error, .progress, .event, .contextCompaction:
- return step.metadata
- }
- }
-
- private static func commandOutputCompletionMetadata(
- from metadata: ReviewLogEntry.Metadata,
- groupID: String?,
- completedAt: Date
- ) -> ReviewLogEntry.Metadata {
- .init(
- sourceType: metadata.sourceType,
- title: metadata.title,
- status: metadata.status,
- detail: metadata.detail,
- itemID: metadata.itemID ?? groupID,
- command: metadata.command,
- cwd: metadata.cwd,
- exitCode: metadata.exitCode,
- startedAt: metadata.startedAt,
- completedAt: metadata.completedAt ?? completedAt,
- durationMs: metadata.durationMs,
- commandActions: metadata.commandActions,
- commandStatus: metadata.commandStatus ?? metadata.status ?? "completed",
- namespace: metadata.namespace,
- server: metadata.server,
- tool: metadata.tool,
- query: metadata.query,
- path: metadata.path,
- resultText: metadata.resultText,
- errorText: metadata.errorText
- )
- }
-
- private static func commandText(from text: String) -> String? {
- let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
- guard trimmed.hasPrefix("$ ") else {
- return trimmed.nilIfEmpty
- }
- return String(trimmed.dropFirst(2)).nilIfEmpty
- }
-
- private static func previewTurnID(_ tick: Int) -> String {
- if tick < 10 {
- return "preview-turn-00\(tick)"
- }
- if tick < 100 {
- return "preview-turn-0\(tick)"
- }
- return "preview-turn-\(tick)"
- }
-
- private static func streamTimeline(from templates: [PreviewStreamTemplate]) -> [PreviewStreamStep?] {
- var timeline: [PreviewStreamStep?] = []
- for template in templates {
- let delay = timeline.isEmpty ? 1 : max(1, template.delayBeforeFrameCount)
- if delay > 1 {
- timeline.append(contentsOf: Array(repeating: nil, count: delay - 1))
- }
- let chunks = template.chunkByWord ? wordChunks(in: template.text) : [template.text]
- for (index, chunk) in chunks.enumerated() {
- if index > 0 && template.chunkIntervalFrameCount > 1 {
- timeline.append(contentsOf: Array(repeating: nil, count: template.chunkIntervalFrameCount - 1))
- }
- timeline.append(PreviewStreamStep(
- kind: template.kind,
- groupName: template.groupName,
- text: chunk,
- metadata: template.metadata,
- replacesGroup: template.replacesGroup
- ))
- }
- }
- return timeline
- }
-
- private static func wordChunks(in text: String) -> [String] {
- let words = text.split(separator: " ", omittingEmptySubsequences: false).map(String.init)
- guard words.isEmpty == false else {
- return [text]
- }
- return words.enumerated().map { offset, word in
- if offset == words.count - 1 {
- return word
- }
- return word + " "
- }
- }
-
- private static let interItemDelayFrameCount = 39
- private static let commandCompletionDelayFrameCount = 60
- private static let compactionCompletionDelayFrameCount = 15
- private static let previewJobTickOffset = 21
-
- private static let previewStreamTemplates: [PreviewStreamTemplate] = [
- .init(
- kind: .event,
- text: "Turn started: \(previewTurnID(1))",
- delayBeforeFrameCount: 1
- ),
- .init(
- kind: .plan,
- groupName: "plan",
- text: """
- [completed] Inspect ReviewMonitor log rendering
- [in_progress] Preserve active find UI while streaming
- [pending] Run focused UI tests
- """,
- delayBeforeFrameCount: interItemDelayFrameCount
- ),
- .init(
- kind: .command,
- groupName: "command-search-test",
- text: "$ /bin/zsh -lc \"rg -n 'ReviewMonitorLog' Sources/ReviewUI && swift test --filter ReviewUI\"",
- delayBeforeFrameCount: interItemDelayFrameCount
- ),
- .init(
- kind: .commandOutput,
- groupName: "command-search-test",
- text: """
- Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift:42: private let logDocumentView = ReviewMonitorLogDocumentView()
- Sources/ReviewUI/Detail/ReviewMonitorLogDocumentView.swift:20: final class ReviewMonitorLogDocumentView
- Test Suite 'ReviewUITests' passed.
- """,
- metadata: .init(
- sourceType: "command",
- title: "Ran command for 5s",
- status: "succeeded",
- exitCode: 0
- ),
- delayBeforeFrameCount: commandCompletionDelayFrameCount
- ),
- .init(
- kind: .toolCall,
- text: "MCP codex_review.review_read started.",
- delayBeforeFrameCount: interItemDelayFrameCount
- ),
- .init(
- kind: .reasoningSummary,
- groupName: "reasoning-summary",
- text: "Checking whether append-only log updates are notifying NSTextFinder while an incremental search is active.\n",
- chunkByWord: true,
- delayBeforeFrameCount: interItemDelayFrameCount
- ),
- .init(
- kind: .command,
- groupName: "command-open-log-scroll",
- text: "$ /bin/zsh -lc \"sed -n '1,240p' Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift\"",
- delayBeforeFrameCount: interItemDelayFrameCount
- ),
- .init(
- kind: .commandOutput,
- groupName: "command-open-log-scroll",
- text: """
- import AppKit
- import ObjectiveC.runtime
- import CodexReview
-
- @MainActor
- final class ReviewMonitorLogScrollView: NSScrollView {
- private let logDocumentView = ReviewMonitorLogDocumentView()
- """,
- metadata: .init(
- sourceType: "command",
- title: "Ran command for 2s",
- status: "succeeded",
- exitCode: 0
- ),
- delayBeforeFrameCount: commandCompletionDelayFrameCount
- ),
- .init(
- kind: .contextCompaction,
- groupName: "context-compaction",
- text: "Automatically compacting context",
- metadata: .init(
- sourceType: "contextCompaction",
- status: "inProgress",
- itemID: "preview-context-compaction"
- ),
- replacesGroup: true,
- delayBeforeFrameCount: interItemDelayFrameCount
- ),
- .init(
- kind: .contextCompaction,
- groupName: "context-compaction",
- text: "Context automatically compacted",
- metadata: .init(
- sourceType: "contextCompaction",
- status: "completed",
- itemID: "preview-context-compaction"
- ),
- replacesGroup: true,
- delayBeforeFrameCount: compactionCompletionDelayFrameCount
- ),
- .init(
- kind: .toolCall,
- text: "File changes updated.",
- delayBeforeFrameCount: interItemDelayFrameCount
- ),
- .init(
- kind: .rawReasoning,
- groupName: "raw-reasoning",
- text: "Need to avoid refreshing the finder client string until the user closes or clears the find bar. Appended text can wait for the next search session.\n",
- chunkByWord: true,
- delayBeforeFrameCount: interItemDelayFrameCount
- ),
- .init(
- kind: .rawReasoning,
- groupName: "raw-reasoning-follow-up",
- text: "I found the log update path and am keeping the current find session stable while new output streams in.\n",
- chunkByWord: true,
- delayBeforeFrameCount: interItemDelayFrameCount
- ),
- .init(
- kind: .agentMessage,
- groupName: "agent-message-summary",
- text: "The preview stream now mixes commands, tool events, reasoning summaries, and visible assistant output instead of one repeated message kind.\n",
- delayBeforeFrameCount: interItemDelayFrameCount
- ),
- ]
-
- private static let previewStreamTimeline = streamTimeline(from: previewStreamTemplates)
-
- private static func makeCommandOutputPreviewLogEntries() -> [ReviewLogEntry] {
- let output = """
- Command line invocation:
- /Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild test -project Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj -scheme CodexReviewMonitor
-
- Resolve Package Graph
-
- Resolved source packages:
- ObservationBridge: /Users/kn/Dev/ObservationBridge
- CodexReviewKit: /Users/kn/Dev/CodexReviewKit
-
- Test Suite 'Selected tests' started.
- Test Case '-[ReviewUITests commandOutputRendersCollapsedTextKitPanel]' passed (0.142 seconds).
- Test Suite 'Selected tests' passed.
- """
- return [
- .init(kind: .event, text: "Turn started: preview-command-output-panel"),
- .init(
- kind: .agentMessage,
- text: """
- Checking the command output rendering path.
-
- - Keep the transcript readable.
- - Collapse terminal output by default.
- - Expand into a bounded TextKit 2 scroll view.
- """
- ),
- .init(
- kind: .command,
- groupID: "preview-command-output",
- text: "$ xcodebuild test -project Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj -scheme CodexReviewMonitor"
- ),
- .init(
- kind: .commandOutput,
- groupID: "preview-command-output",
- text: output,
- metadata: .init(
- sourceType: "command",
- title: "Ran command for 17s",
- status: "succeeded",
- exitCode: 0
- )
- ),
- .init(
- kind: .agentMessage,
- text: "The output remains available without taking over the whole log."
- ),
- ]
- }
-
- @_spi(PreviewSupport)
- public static func makePreviewAccounts() -> [CodexAccount] {
- [
- makePreviewAccount(
- email: "workspace@example.com",
- usedPercents: (short: 34, long: 61)
- ),
- makePreviewAccount(
- email: "personal@example.com",
- usedPercents: (short: 12, long: 27)
- ),
- makePreviewAccount(
- email: "team@example.com",
- usedPercents: (short: 72, long: 44)
- ),
- ]
- }
-
- @_spi(PreviewSupport)
- public static func makePreviewAccount(
- email: String = "review@example.com",
- usedPercents: (short: Int, long: Int) = (short: 34, long: 61)
- ) -> CodexAccount {
- let account = CodexAccount(email: email, planType: "pro")
- account.updateRateLimits(
- [
- (
- windowDurationMinutes: 300,
- usedPercent: usedPercents.short,
- resetsAt: Date.now.addingTimeInterval(60 * 60)
- ),
- (
- windowDurationMinutes: 10_080,
- usedPercent: usedPercents.long,
- resetsAt: Date.now.addingTimeInterval(24 * 60 * 60)
- ),
- ]
- )
- return account
- }
-
- static func makePreviewSettingsSnapshot() -> CodexReviewSettings.Snapshot {
- .init(
- model: "gpt-5.4",
- reasoningEffort: .medium,
- serviceTier: .fast,
- models: makePreviewModelCatalog()
- )
- }
-
- static func makePreviewModelCatalog() -> [CodexReviewSettings.ModelCatalogItem] {
- [
- .init(
- id: "gpt-5.4",
- model: "gpt-5.4",
- displayName: "GPT-5.4",
- hidden: false,
- supportedReasoningEfforts: [
- .init(reasoningEffort: .low, description: "Lower latency."),
- .init(reasoningEffort: .medium, description: "Balanced default."),
- .init(reasoningEffort: .high, description: "More deliberation."),
- ],
- defaultReasoningEffort: .medium,
- supportedServiceTiers: [.fast]
- ),
- .init(
- id: "gpt-5.4-mini",
- model: "gpt-5.4-mini",
- displayName: "GPT-5.4 Mini",
- hidden: false,
- supportedReasoningEfforts: [
- .init(reasoningEffort: .low, description: "Quick pass."),
- .init(reasoningEffort: .medium, description: "Balanced default."),
- ],
- defaultReasoningEffort: .medium,
- supportedServiceTiers: []
- ),
- .init(
- id: "gpt-5.3-codex",
- model: "gpt-5.3-codex",
- displayName: "GPT-5.3 Codex",
- hidden: false,
- supportedReasoningEfforts: [
- .init(reasoningEffort: .minimal, description: "Lowest overhead."),
- .init(reasoningEffort: .low, description: "Faster iteration."),
- .init(reasoningEffort: .medium, description: "Balanced default."),
- ],
- defaultReasoningEffort: .medium,
- supportedServiceTiers: [.fast, .flex]
- ),
- ]
- }
-
- private static func makeSidebarContent() -> (workspaces: [CodexReviewWorkspace], jobs: [CodexReviewJob]) {
- let now = Date()
- let workspacePaths = [
- "/path/to/workspace-alpha",
- "/path/to/workspace-beta",
- "/path/to/workspace-gamma",
- ]
-
- var workspaces: [CodexReviewWorkspace] = []
- var jobs: [CodexReviewJob] = []
- for (workspaceIndex, cwd) in workspacePaths.enumerated() {
- let workspaceName = URL(fileURLWithPath: cwd).lastPathComponent
- workspaces.append(CodexReviewWorkspace(cwd: cwd))
- jobs += makeJobDefinitions(for: workspaceName).enumerated().map { jobIndex, definition in
- makeJob(
- id: "preview-\(workspaceIndex)-\(jobIndex)",
- cwd: cwd,
- model: definition.model,
- status: definition.status,
- targetSummary: definition.targetSummary,
- startedAt: definition.startedOffset.map { now.addingTimeInterval($0) },
- endedAt: definition.endedOffset.map { now.addingTimeInterval($0) },
- summary: definition.summary,
- hasFinalReview: definition.hasFinalReview,
- reviewResult: makeReviewResult(
- workspaceIndex: workspaceIndex,
- jobIndex: jobIndex,
- cwd: cwd
- ),
- lastAgentMessage: definition.lastAgentMessage,
- logEntries: makePreviewLogEntries(for: definition, workspaceName: workspaceName)
- )
- }
- }
- return (workspaces, jobs)
- }
-
- private static func makeJob(
- id: String,
- cwd: String,
- model: String,
- status: ReviewJobState,
- targetSummary: String,
- startedAt: Date?,
- endedAt: Date?,
- summary: String,
- hasFinalReview: Bool,
- reviewResult: ParsedReviewResult?,
- lastAgentMessage: String,
- logEntries: [ReviewLogEntry]
- ) -> CodexReviewJob {
- CodexReviewJob.makeForTesting(
- id: id,
- cwd: cwd,
- targetSummary: targetSummary,
- model: model,
- threadID: UUID().uuidString,
- turnID: UUID().uuidString,
- status: status,
- startedAt: startedAt,
- endedAt: endedAt,
- summary: summary,
- hasFinalReview: hasFinalReview,
- reviewResult: reviewResult,
- lastAgentMessage: lastAgentMessage,
- logEntries: logEntries
- )
- }
-
- private static func makeReviewResult(
- workspaceIndex: Int,
- jobIndex: Int,
- cwd: String
- ) -> ParsedReviewResult? {
- guard workspaceIndex == 0,
- jobIndex == 3
- else {
- return nil
- }
-
- return .init(
- state: .hasFindings,
- findingCount: 2,
- findings: [
- .init(
- title: "[P1] Keep workspace selection wired to findings",
- body: "The preview should make the first workspace show structured review findings as soon as it is selected.",
- priority: 1,
- location: .init(
- path: "\(cwd)/Sources/ReviewMonitorContentView.swift",
- startLine: 42,
- endLine: 48
- ),
- rawText: ""
- ),
- .init(
- title: "[P2] Preserve review mode preview data",
- body: "The application review mode should keep using the same preview store as Xcode previews.",
- priority: 2,
- location: .init(
- path: "\(cwd)/Tools/ReviewMonitor/ReviewMonitorApp.swift",
- startLine: 96,
- endLine: 104
- ),
- rawText: ""
- ),
- ],
- source: .parsedFinalReviewText
- )
- }
-
- private static func makePreviewLogEntries(
- for definition: PreviewJobDefinition,
- workspaceName: String
- ) -> [ReviewLogEntry] {
- switch definition.status {
- case .running:
- makeRunningPreviewLogEntries(for: definition, workspaceName: workspaceName)
- case .queued:
- [
- .init(kind: .event, text: "Queued review for \(definition.targetSummary)."),
- .init(kind: .progress, text: definition.summary),
- ]
- case .failed:
- [
- .init(kind: .event, text: "Turn started: preview-failed-\(workspaceName.lowercased())"),
- .init(
- kind: .command,
- groupID: "preview-failed-command-\(workspaceName)-\(definition.targetSummary)",
- text: "$ /bin/zsh -lc \"swift test --build-system swiftbuild --no-parallel\""
- ),
- .init(
- kind: .commandOutput,
- groupID: "preview-failed-command-\(workspaceName)-\(definition.targetSummary)",
- text: """
- Building for debugging...
- Test Suite 'ReviewUITests' started.
- ReviewMonitorContentPreviewTests.testPreviewStore failed: expected command output panel metadata.
- """,
- metadata: .init(
- sourceType: "command",
- title: "Ran command for 10s",
- status: "failed",
- exitCode: 1
- )
- ),
- .init(kind: .error, text: definition.summary),
- .init(kind: .agentMessage, text: definition.lastAgentMessage),
- ]
- case .cancelled:
- [
- .init(kind: .event, text: "Turn started: preview-cancelled-\(workspaceName.lowercased())"),
- .init(kind: .progress, text: definition.summary),
- .init(kind: .agentMessage, text: definition.lastAgentMessage),
- ]
- case .succeeded:
- [
- .init(kind: .event, text: "Turn started: preview-complete-\(workspaceName.lowercased())"),
- .init(
- kind: .command,
- groupID: "preview-complete-command-\(workspaceName)-\(definition.targetSummary)",
- text: "$ /bin/zsh -lc \"swift test --filter ReviewUI\""
- ),
- .init(
- kind: .commandOutput,
- groupID: "preview-complete-command-\(workspaceName)-\(definition.targetSummary)",
- text: """
- Test Suite 'ReviewUITests' started.
- Test commandOutputRendersCollapsedTextKitPanelAndExpandsInline passed.
- Test Suite 'ReviewUITests' passed.
- """,
- metadata: .init(
- sourceType: "command",
- title: "Ran command for 4s",
- status: "succeeded",
- exitCode: 0
- )
- ),
- .init(
- kind: .reasoningSummary,
- groupID: "preview-complete-summary-\(workspaceName)-\(definition.targetSummary)",
- text: definition.summary
- ),
- .init(kind: .agentMessage, text: definition.lastAgentMessage),
- ]
- }
- }
-
- private static func makeRunningPreviewLogEntries(
- for definition: PreviewJobDefinition,
- workspaceName: String
- ) -> [ReviewLogEntry] {
- let sourceReadGroupID = "preview-initial-source-read-\(workspaceName)-\(definition.targetSummary)"
- let sourceReadCommand = "sed -n '1,260p' Sources/ReviewUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift"
- return [
- .init(kind: .event, text: "Turn started: preview-\(workspaceName.lowercased())"),
- .init(kind: .progress, text: "Reviewing \(definition.targetSummary)"),
- .init(
- kind: .contextCompaction,
- groupID: "preview-initial-context-compaction-\(workspaceName)-\(definition.targetSummary)",
- replacesGroup: true,
- text: "Context automatically compacted",
- metadata: .init(
- sourceType: "contextCompaction",
- status: "completed",
- itemID: "preview-initial-context-compaction-\(workspaceName)-\(definition.targetSummary)"
- )
- ),
- .init(
- kind: .plan,
- groupID: "preview-initial-plan-\(workspaceName)-\(definition.targetSummary)",
- replacesGroup: true,
- text: """
- [completed] Inspect changed files
- [in_progress] Check ReviewMonitor log behavior
- [pending] Run focused tests
- """
- ),
- .init(
- kind: .command,
- groupID: "preview-initial-command-\(workspaceName)-\(definition.targetSummary)",
- text: "$ /bin/zsh -lc \"git diff --stat && rg -n 'ReviewMonitor' Sources Tests\""
- ),
- .init(
- kind: .commandOutput,
- groupID: "preview-initial-command-\(workspaceName)-\(definition.targetSummary)",
- text: """
- Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift | 34 +++++++++++++++++
- Sources/ReviewUI/Detail/ReviewMonitorLogDocumentView.swift | 18 ++++++++--
- Tests/ReviewUITests/ReviewUITests.swift | 12 ++++++
-
- Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift:42: private let logDocumentView = ReviewMonitorLogDocumentView()
- Tests/ReviewUITests/ReviewUITests.swift:2322: commandOutputRendersCollapsedTextKitPanelAndExpandsInline
- """,
- metadata: .init(
- sourceType: "command",
- title: "Ran command for 6s",
- status: "succeeded",
- exitCode: 0
- )
- ),
- .init(
- kind: .command,
- groupID: sourceReadGroupID,
- text: "$ /bin/zsh -lc \"\(sourceReadCommand)\"",
- metadata: .init(
- sourceType: "commandExecution",
- status: "inProgress",
- itemID: sourceReadGroupID,
- command: "/bin/zsh -lc \"\(sourceReadCommand)\"",
- startedAt: Date().addingTimeInterval(-88),
- commandActions: [
- .init(
- kind: .read,
- command: sourceReadCommand,
- name: "ReviewMonitorCommandOutputDisplayDocument.swift",
- path: "Sources/ReviewUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift"
- )
- ],
- commandStatus: "inProgress"
- ),
- ),
- .init(kind: .toolCall, text: "MCP codex_review.review_start started."),
- .init(
- kind: .reasoningSummary,
- groupID: "preview-initial-summary-\(workspaceName)-\(definition.targetSummary)",
- text: "I am comparing the current UI state with the streaming log updates before changing the finder integration."
- ),
- .init(
- kind: .agentMessage,
- groupID: "preview-initial-agent-\(workspaceName)-\(definition.targetSummary)",
- text: definition.lastAgentMessage
- ),
- ]
- }
-
- private static func makeJobDefinitions(for workspaceName: String) -> [PreviewJobDefinition] {
- [
- PreviewJobDefinition(
- status: .running,
- targetSummary: "Branch: feature/\(workspaceName.lowercased())-sidebar",
- summary: "Review is streaming updates from the embedded server.",
- lastAgentMessage: "Inspecting recent sidebar changes and collecting render timings.",
- model: "gpt-5.4",
- startedOffset: -420,
- endedOffset: nil,
- hasFinalReview: false
- ),
- PreviewJobDefinition(
- status: .running,
- targetSummary: "Uncommitted changes",
- summary: "The working tree review is still in progress.",
- lastAgentMessage: "Comparing row reuse behavior across the latest local edits.",
- model: "gpt-5.4-mini",
- startedOffset: -135,
- endedOffset: nil,
- hasFinalReview: false
- ),
- PreviewJobDefinition(
- status: .queued,
- targetSummary: "Base branch: main",
- summary: "Queued behind another active review in this workspace.",
- lastAgentMessage: "Waiting for an available backend slot.",
- model: "gpt-5.3-codex",
- startedOffset: nil,
- endedOffset: nil,
- hasFinalReview: false
- ),
- PreviewJobDefinition(
- status: .succeeded,
- targetSummary: "Commit: abc1234",
- summary: "Review completed without correctness findings.",
- lastAgentMessage: "No correctness issues found in the touched files.",
- model: "gpt-5.4",
- startedOffset: -1_500,
- endedOffset: -1_260,
- hasFinalReview: true
- ),
- PreviewJobDefinition(
- status: .failed,
- targetSummary: "Custom: investigate CI flake",
- summary: "The review stopped after the test command failed.",
- lastAgentMessage: "Build failed before the model could finish evaluating the patch.",
- model: "gpt-5.3-codex",
- startedOffset: -2_400,
- endedOffset: -2_190,
- hasFinalReview: false
- ),
- PreviewJobDefinition(
- status: .cancelled,
- targetSummary: "Branch: feature/\(workspaceName.lowercased())-transport",
- summary: "Cancellation was requested after initial diagnostics completed.",
- lastAgentMessage: "Stopped after the first pass to free the session for a retry.",
- model: "gpt-5.4-mini",
- startedOffset: -960,
- endedOffset: -840,
- hasFinalReview: false
- ),
- PreviewJobDefinition(
- status: .succeeded,
- targetSummary: "Commit: def5678",
- summary: "Review suggested a small cleanup in the sidebar row renderer.",
- lastAgentMessage: "Suggested simplifying duplicated state handling in the row view.",
- model: "gpt-5.4",
- startedOffset: -5_400,
- endedOffset: -5_040,
- hasFinalReview: true
- ),
- ]
- }
-}
diff --git a/Sources/ReviewUI/ReviewMonitorSelection.swift b/Sources/ReviewUI/ReviewMonitorSelection.swift
new file mode 100644
index 00000000..27093faf
--- /dev/null
+++ b/Sources/ReviewUI/ReviewMonitorSelection.swift
@@ -0,0 +1,22 @@
+import CodexKit
+import CodexReviewKit
+
+enum ReviewMonitorSelectionID: Hashable, Sendable {
+ case workspaceGroup(CodexWorkspaceGroupID)
+ case chat(CodexThreadID)
+}
+
+@MainActor
+enum ReviewMonitorSelection: Hashable, Sendable {
+ case workspaceGroup(CodexWorkspaceGroupID)
+ case chat(CodexThreadID)
+
+ var id: ReviewMonitorSelectionID {
+ switch self {
+ case .workspaceGroup(let id):
+ return .workspaceGroup(id)
+ case .chat(let id):
+ return .chat(id)
+ }
+ }
+}
diff --git a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift
index 3d711cba..072fdeab 100644
--- a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift
+++ b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift
@@ -1,8 +1,9 @@
import AppKit
import Combine
+import CodexKit
import Foundation
import ObservationBridge
-import CodexReview
+import CodexReviewKit
@MainActor
final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDelegate {
@@ -13,12 +14,13 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe
private static let addAccountToolbarItemIdentifier = NSToolbarItem.Identifier(
"CodexReviewKit.ReviewMonitor.Toolbar.AddAccount"
)
- private static let sidebarJobFilterToolbarItemIdentifier = NSToolbarItem.Identifier(
- "CodexReviewKit.ReviewMonitor.Toolbar.SidebarJobFilter"
+ private static let sidebarReviewChatFilterToolbarItemIdentifier = NSToolbarItem.Identifier(
+ "CodexReviewKit.ReviewMonitor.Toolbar.SidebarReviewChatFilter"
)
private let store: CodexReviewStore
private let uiState: ReviewMonitorUIState
+ private let codexModelSource: ReviewMonitorCodexModelSource?
private let showSettings: (@MainActor () -> Void)?
private var sidebarViewController: ReviewMonitorSidebarViewController?
private var transportViewController: ReviewMonitorTransportViewController?
@@ -26,22 +28,40 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe
private var contentItem: NSSplitViewItem?
private var toolbar: NSToolbar?
private var sidebarPickerToolbarItem: ReviewMonitorSidebarPickerToolbarItem?
- private var sidebarJobFilterToolbarItem: ReviewMonitorSidebarJobFilterToolbarItem?
+ private var sidebarReviewChatFilterToolbarItem: ReviewMonitorSidebarReviewChatFilterToolbarItem?
private var addAccountToolbarItem: ReviewMonitorAddAccountToolbarItem?
private var toolbarMembershipObservation: PortableObservationTracking.Token?
private var windowTitleObservation: PortableObservationTracking.Token?
+ private var codexSelectionTitleResolver: ReviewMonitorCodexSelectionTitleResolver?
+ private weak var codexSelectionTitleResolverContext: CodexModelContext?
private var sidebarCollapseObservation: NSKeyValueObservation?
private var windowCancellable: AnyCancellable?
private weak var attachedWindow: NSWindow?
private var isSidebarCollapsed = false
+ convenience init(
+ store: CodexReviewStore,
+ uiState: ReviewMonitorUIState,
+ modelContext: CodexModelContext,
+ showSettings: (@MainActor () -> Void)? = nil
+ ) {
+ self.init(
+ store: store,
+ uiState: uiState,
+ codexModelSource: ReviewMonitorCodexModelSource(modelContext: modelContext),
+ showSettings: showSettings
+ )
+ }
+
init(
store: CodexReviewStore,
uiState: ReviewMonitorUIState,
+ codexModelSource: ReviewMonitorCodexModelSource? = nil,
showSettings: (@MainActor () -> Void)? = nil
) {
self.store = store
self.uiState = uiState
+ self.codexModelSource = codexModelSource
self.showSettings = showSettings
super.init(nibName: nil, bundle: nil)
}
@@ -60,11 +80,12 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe
let sidebarViewController = ReviewMonitorSidebarViewController(
store: store,
- uiState: uiState
+ uiState: uiState,
+ codexModelSource: codexModelSource
)
let transportViewController = ReviewMonitorTransportViewController(
- store: store,
- uiState: uiState
+ uiState: uiState,
+ codexModelSource: codexModelSource
)
let statusAccessoryViewController = ReviewMonitorServerStatusAccessoryViewController(
store: store,
@@ -134,6 +155,7 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe
func attach(to window: NSWindow) {
loadViewIfNeeded()
+ sidebarViewController?.loadViewIfNeeded()
let isNewWindow = attachedWindow !== window
attachedWindow = window
@@ -149,7 +171,7 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe
}
window.layoutIfNeeded()
synchronizeSidebarToolbarState()
- applyWindowTitle(Self.windowTitlePresentation(for: uiState.selection))
+ applyWindowTitle(windowTitlePresentation(for: uiState.selection))
}
func detachFromWindow() {
@@ -171,17 +193,21 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe
isSidebarCollapsed: self.isSidebarCollapsed,
isAuthenticating: isAuthenticating
)
- let isShowingSidebarJobFilter = Self.isShowingSidebarJobFilterToolbarItem(
+ let isShowingSidebarReviewChatFilter = Self.isShowingSidebarReviewChatFilterToolbarItem(
sidebarSelection: sidebarSelection,
isSidebarCollapsed: self.isSidebarCollapsed
)
self.setShowingAddAccount(isShowingAddAccount)
- self.setShowingSidebarJobFilter(isShowingSidebarJobFilter)
+ self.setShowingSidebarReviewChatFilter(isShowingSidebarReviewChatFilter)
}
windowTitleObservation = withPortableContinuousObservation { [weak self, uiState] _ in
- let presentation = Self.windowTitlePresentation(for: uiState.selection)
- self?.applyWindowTitle(presentation)
+ guard let self else {
+ return
+ }
+ _ = self.codexModelSource?.modelContext
+ let presentation = self.windowTitlePresentation(for: uiState.selection)
+ self.applyWindowTitle(presentation)
}
}
@@ -212,23 +238,39 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe
var subtitle: String
}
- private static func windowTitlePresentation(
+ private func windowTitlePresentation(
for selection: ReviewMonitorSelection?
) -> WindowTitlePresentation {
- switch selection {
- case .workspaceSection(let section):
- WindowTitlePresentation(
- title: section.title,
- subtitle: section.subtitle
- )
- case .job(let job):
- WindowTitlePresentation(
- title: job.targetSummary,
- subtitle: job.cwd
- )
- case nil:
- WindowTitlePresentation(title: "", subtitle: "")
+ guard let presentation = codexSelectionTitlePresentation(for: selection) else {
+ return WindowTitlePresentation(title: "", subtitle: "")
+ }
+ return WindowTitlePresentation(
+ title: presentation.title,
+ subtitle: presentation.subtitle
+ )
+ }
+
+ private func codexSelectionTitlePresentation(
+ for selection: ReviewMonitorSelection?
+ ) -> ReviewMonitorCodexSelectionTitlePresentation? {
+ guard let modelContext = codexModelSource?.modelContext else {
+ return nil
}
+ let resolver = codexSelectionTitleResolver(for: modelContext)
+ return resolver.titlePresentation(for: selection)
+ }
+
+ private func codexSelectionTitleResolver(
+ for modelContext: CodexModelContext
+ ) -> ReviewMonitorCodexSelectionTitleResolver {
+ if let codexSelectionTitleResolver,
+ codexSelectionTitleResolverContext === modelContext {
+ return codexSelectionTitleResolver
+ }
+ let resolver = ReviewMonitorCodexSelectionTitleResolver(modelContext: modelContext)
+ codexSelectionTitleResolver = resolver
+ codexSelectionTitleResolverContext = modelContext
+ return resolver
}
private func applyWindowTitle(_ presentation: WindowTitlePresentation) {
@@ -245,7 +287,7 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe
[
Self.sidebarPickerToolbarItemIdentifier,
.flexibleSpace,
- Self.sidebarJobFilterToolbarItemIdentifier,
+ Self.sidebarReviewChatFilterToolbarItemIdentifier,
.sidebarTrackingSeparator,
.flexibleSpace,
]
@@ -255,7 +297,7 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe
[
Self.sidebarPickerToolbarItemIdentifier,
Self.addAccountToolbarItemIdentifier,
- Self.sidebarJobFilterToolbarItemIdentifier,
+ Self.sidebarReviewChatFilterToolbarItemIdentifier,
.sidebarTrackingSeparator,
.space,
.flexibleSpace,
@@ -275,8 +317,8 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe
let item = resolvedAddAccountToolbarItem()
return item
- case Self.sidebarJobFilterToolbarItemIdentifier:
- return resolvedSidebarJobFilterToolbarItem()
+ case Self.sidebarReviewChatFilterToolbarItemIdentifier:
+ return resolvedSidebarReviewChatFilterToolbarItem()
case .sidebarTrackingSeparator:
return NSTrackingSeparatorToolbarItem(
@@ -305,16 +347,16 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe
return item
}
- private func resolvedSidebarJobFilterToolbarItem() -> ReviewMonitorSidebarJobFilterToolbarItem {
- if let sidebarJobFilterToolbarItem {
- return sidebarJobFilterToolbarItem
+ private func resolvedSidebarReviewChatFilterToolbarItem() -> ReviewMonitorSidebarReviewChatFilterToolbarItem {
+ if let sidebarReviewChatFilterToolbarItem {
+ return sidebarReviewChatFilterToolbarItem
}
- let item = ReviewMonitorSidebarJobFilterToolbarItem(
- itemIdentifier: Self.sidebarJobFilterToolbarItemIdentifier,
+ let item = ReviewMonitorSidebarReviewChatFilterToolbarItem(
+ itemIdentifier: Self.sidebarReviewChatFilterToolbarItemIdentifier,
uiState: uiState
)
- sidebarJobFilterToolbarItem = item
+ sidebarReviewChatFilterToolbarItem = item
return item
}
@@ -353,9 +395,9 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe
)
}
- private func updateSidebarJobFilterToolbarVisibility() {
- setShowingSidebarJobFilter(
- Self.isShowingSidebarJobFilterToolbarItem(
+ private func updateSidebarReviewChatFilterToolbarVisibility() {
+ setShowingSidebarReviewChatFilter(
+ Self.isShowingSidebarReviewChatFilterToolbarItem(
sidebarSelection: uiState.sidebarSelection,
isSidebarCollapsed: isSidebarCollapsed
)
@@ -373,7 +415,7 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe
return sidebarSelection == .account && isSidebarCollapsed == false
}
- private static func isShowingSidebarJobFilterToolbarItem(
+ private static func isShowingSidebarReviewChatFilterToolbarItem(
sidebarSelection: SidebarPickerSelection,
isSidebarCollapsed: Bool
) -> Bool {
@@ -386,13 +428,13 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe
}
isSidebarCollapsed = isCollapsed
updateAddAccountToolbarVisibility()
- updateSidebarJobFilterToolbarVisibility()
+ updateSidebarReviewChatFilterToolbarVisibility()
}
private func synchronizeSidebarToolbarState() {
isSidebarCollapsed = sidebarItem?.isCollapsed ?? isSidebarCollapsed
updateAddAccountToolbarVisibility()
- updateSidebarJobFilterToolbarVisibility()
+ updateSidebarReviewChatFilterToolbarVisibility()
}
private func setShowingAddAccount(_ isShowing: Bool) {
@@ -421,17 +463,17 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe
)
}
- private func setShowingSidebarJobFilter(_ isShowing: Bool) {
+ private func setShowingSidebarReviewChatFilter(_ isShowing: Bool) {
guard let toolbar else {
return
}
let existingIndexes = toolbar.items.indices.filter { index in
- toolbar.items[index].itemIdentifier == Self.sidebarJobFilterToolbarItemIdentifier
+ toolbar.items[index].itemIdentifier == Self.sidebarReviewChatFilterToolbarItemIdentifier
}
if existingIndexes.isEmpty == false {
if isShowing {
- ensureSidebarJobFilterToolbarItem(in: toolbar)
+ ensureSidebarReviewChatFilterToolbarItem(in: toolbar)
return
}
for index in existingIndexes.reversed() {
@@ -448,14 +490,14 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe
$0.itemIdentifier == .sidebarTrackingSeparator
}) ?? toolbar.items.count
toolbar.insertItem(
- withItemIdentifier: Self.sidebarJobFilterToolbarItemIdentifier,
+ withItemIdentifier: Self.sidebarReviewChatFilterToolbarItemIdentifier,
at: sidebarInsertionIndex
)
}
- private func ensureSidebarJobFilterToolbarItem(in toolbar: NSToolbar) {
+ private func ensureSidebarReviewChatFilterToolbarItem(in toolbar: NSToolbar) {
guard toolbar.items.contains(where: {
- $0.itemIdentifier == Self.sidebarJobFilterToolbarItemIdentifier
+ $0.itemIdentifier == Self.sidebarReviewChatFilterToolbarItemIdentifier
}) == false else {
return
}
@@ -464,20 +506,30 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe
$0.itemIdentifier == .sidebarTrackingSeparator
}) ?? toolbar.items.count
toolbar.insertItem(
- withItemIdentifier: Self.sidebarJobFilterToolbarItemIdentifier,
+ withItemIdentifier: Self.sidebarReviewChatFilterToolbarItemIdentifier,
at: sidebarInsertionIndex
)
}
private func sidebarTrailingInsertionIndex(for toolbar: NSToolbar) -> Int {
toolbar.items.firstIndex {
- $0.itemIdentifier == Self.sidebarJobFilterToolbarItemIdentifier ||
+ $0.itemIdentifier == Self.sidebarReviewChatFilterToolbarItemIdentifier ||
$0.itemIdentifier == .sidebarTrackingSeparator
} ?? toolbar.items.count
}
}
+@MainActor
+extension ReviewMonitorSplitViewController {
+ func prepareForImmediateRenderingForPreviewSupport() {
+ loadViewIfNeeded()
+ sidebarViewController?.loadViewIfNeeded()
+ transportViewController?.loadViewIfNeeded()
+ view.layoutSubtreeIfNeeded()
+ }
+}
+
#if DEBUG
@MainActor
extension ReviewMonitorSplitViewController {
@@ -487,7 +539,7 @@ extension ReviewMonitorSplitViewController {
}
enum SidebarPresentationForTesting: Sendable, Equatable {
- case jobList
+ case chatList
case accountList
case unavailable
}
@@ -502,8 +554,8 @@ extension ReviewMonitorSplitViewController {
var sidebarPresentationForTesting: SidebarPresentationForTesting {
switch sidebarViewControllerForTesting.sidebarKindForTesting {
- case .jobList, .empty:
- return .jobList
+ case .chatList, .empty:
+ return .chatList
case .accountList:
return .accountList
case .unavailable:
@@ -535,6 +587,10 @@ extension ReviewMonitorSplitViewController {
transportViewControllerForTesting
}
+ var isTransportViewLoadedForTesting: Bool {
+ transportViewController?.isViewLoaded ?? false
+ }
+
var transportViewControllerForTesting: ReviewMonitorTransportViewController {
guard let transportViewController else {
fatalError("Transport view controller is not configured yet.")
@@ -551,8 +607,8 @@ extension ReviewMonitorSplitViewController {
Self.sidebarPickerToolbarItemIdentifier
}
- var sidebarJobFilterToolbarItemIdentifierForTesting: NSToolbarItem.Identifier {
- Self.sidebarJobFilterToolbarItemIdentifier
+ var sidebarReviewChatFilterToolbarItemIdentifierForTesting: NSToolbarItem.Identifier {
+ Self.sidebarReviewChatFilterToolbarItemIdentifier
}
var sidebarPickerToolbarSegmentAccessibilityDescriptionsForTesting: [String] {
@@ -589,41 +645,41 @@ extension ReviewMonitorSplitViewController {
sidebarPickerToolbarItem.selectOverflowMenuItemForTesting(selection)
}
- var sidebarJobFilterToolbarItemIsHiddenForTesting: Bool {
+ var sidebarReviewChatFilterToolbarItemIsHiddenForTesting: Bool {
toolbar?.items.contains(where: {
- $0.itemIdentifier == Self.sidebarJobFilterToolbarItemIdentifier
+ $0.itemIdentifier == Self.sidebarReviewChatFilterToolbarItemIdentifier
}) != true
}
- var sidebarJobFilterToolbarShowsActiveBackgroundForTesting: Bool {
- sidebarJobFilterToolbarItem?.buttonShowsActiveBackgroundForTesting ?? false
+ var sidebarReviewChatFilterToolbarShowsActiveBackgroundForTesting: Bool {
+ sidebarReviewChatFilterToolbarItem?.buttonShowsActiveBackgroundForTesting ?? false
}
var selectedToolbarItemIdentifierForTesting: NSToolbarItem.Identifier? {
toolbar?.selectedItemIdentifier
}
- var sidebarJobFilterToolbarMenuItemTitlesForTesting: [String] {
- sidebarJobFilterToolbarItem?.menuItemTitlesForTesting ?? []
+ var sidebarReviewChatFilterToolbarMenuItemTitlesForTesting: [String] {
+ sidebarReviewChatFilterToolbarItem?.menuItemTitlesForTesting ?? []
}
- var sidebarJobFilterToolbarSelectedMenuItemTitlesForTesting: [String] {
- sidebarJobFilterToolbarItem?.selectedMenuItemTitlesForTesting ?? []
+ var sidebarReviewChatFilterToolbarSelectedMenuItemTitlesForTesting: [String] {
+ sidebarReviewChatFilterToolbarItem?.selectedMenuItemTitlesForTesting ?? []
}
- var sidebarJobFilterToolbarSelectedFilterForTesting: SidebarJobFilter? {
- sidebarJobFilterToolbarItem?.selectedFilterForTesting
+ var sidebarReviewChatFilterToolbarSelectedFilterForTesting: SidebarReviewChatFilter? {
+ sidebarReviewChatFilterToolbarItem?.selectedFilterForTesting
}
- func setSidebarJobFilterForTesting(_ filter: SidebarJobFilter) {
- uiState.sidebarJobFilter = filter
+ func setSidebarReviewChatFilterForTesting(_ filter: SidebarReviewChatFilter) {
+ uiState.sidebarReviewChatFilter = filter
}
- func selectSidebarJobFilterForTesting(_ filter: SidebarJobFilter) {
- guard let sidebarJobFilterToolbarItem else {
- fatalError("Sidebar job filter toolbar item is not configured yet.")
+ func selectSidebarReviewChatFilterForTesting(_ filter: SidebarReviewChatFilter) {
+ guard let sidebarReviewChatFilterToolbarItem else {
+ fatalError("Sidebar review chat filter toolbar item is not configured yet.")
}
- sidebarJobFilterToolbarItem.selectFilterForTesting(filter)
+ sidebarReviewChatFilterToolbarItem.selectFilterForTesting(filter)
}
var addAccountToolbarItemIdentifierForTesting: NSToolbarItem.Identifier {
diff --git a/Sources/ReviewUI/ReviewMonitorUIState.swift b/Sources/ReviewUI/ReviewMonitorUIState.swift
index f50ae484..2aba199d 100644
--- a/Sources/ReviewUI/ReviewMonitorUIState.swift
+++ b/Sources/ReviewUI/ReviewMonitorUIState.swift
@@ -1,56 +1,39 @@
import Observation
-import CodexReview
-import Foundation
-import SwiftUI
+import CodexKit
+import CodexReviewKit
@MainActor
@Observable
-final class ReviewMonitorUIState {
+public final class ReviewMonitorUIState {
let auth: CodexReviewAuthModel
- private let persistSidebarJobFilter: (SidebarJobFilter) -> Void
+ private let persistSidebarReviewChatFilter: (SidebarReviewChatFilter) -> Void
var selection: ReviewMonitorSelection?
var sidebarSelection = SidebarPickerSelection.workspace
- var sidebarJobFilter: SidebarJobFilter {
+ var sidebarReviewChatFilter: SidebarReviewChatFilter {
didSet {
- guard sidebarJobFilter != oldValue else {
+ guard sidebarReviewChatFilter != oldValue else {
return
}
- persistSidebarJobFilter(sidebarJobFilter)
+ persistSidebarReviewChatFilter(sidebarReviewChatFilter)
}
}
- init(
+ package init(
auth: CodexReviewAuthModel,
- sidebarJobFilter: SidebarJobFilter = .all,
- persistSidebarJobFilter: @escaping (SidebarJobFilter) -> Void = { _ in }
+ sidebarReviewChatFilter: SidebarReviewChatFilter = .all,
+ persistSidebarReviewChatFilter: @escaping (SidebarReviewChatFilter) -> Void = { _ in }
) {
self.auth = auth
- self.sidebarJobFilter = sidebarJobFilter
- self.persistSidebarJobFilter = persistSidebarJobFilter
+ self.sidebarReviewChatFilter = sidebarReviewChatFilter
+ self.persistSidebarReviewChatFilter = persistSidebarReviewChatFilter
}
- var selectedJobEntry: CodexReviewJob? {
- get {
- guard case .job(let job) = selection else {
- return nil
- }
- return job
- }
- set {
- selection = newValue.map(ReviewMonitorSelection.job)
- }
+ package func selectChat(id: CodexThreadID?) {
+ selection = id.map(ReviewMonitorSelection.chat)
}
- var selectedWorkspaceSectionEntry: ReviewMonitorWorkspaceSectionSelection? {
- get {
- guard case .workspaceSection(let section) = selection else {
- return nil
- }
- return section
- }
- set {
- selection = newValue.map(ReviewMonitorSelection.workspaceSection)
- }
+ var selectionID: ReviewMonitorSelectionID? {
+ selection?.id
}
var contentKind: ReviewMonitorContentKind {
@@ -60,112 +43,3 @@ final class ReviewMonitorUIState {
return .signInView
}
}
-
-@MainActor
-enum ReviewMonitorSelection {
- case workspaceSection(ReviewMonitorWorkspaceSectionSelection)
- case job(CodexReviewJob)
-}
-
-enum ReviewMonitorContentKind: Equatable ,CaseIterable{
- case contentView
- case signInView
-}
-
-enum SidebarPickerSelection: CaseIterable, Hashable {
- case workspace
- case account
-
- var localized: LocalizedStringResource {
- switch self {
- case .workspace:
- "Workspace"
- case .account:
- "Account"
- }
- }
-
- var systemImage: String {
- switch self {
- case .workspace:
- "list.bullet"
- case .account:
- "person"
- }
- }
-}
-
-struct SidebarJobFilter: OptionSet, Hashable, Sendable {
- let rawValue: Int
-
- static let all: SidebarJobFilter = []
- static let running = SidebarJobFilter(rawValue: 1 << 0)
- static let latestFinished = SidebarJobFilter(rawValue: 1 << 1)
- static let menuFilters: [SidebarJobFilter] = [.running, .latestFinished]
-
- init(rawValue: Int) {
- self.rawValue = rawValue
- }
-
- var localized: LocalizedStringResource {
- if self == .all {
- "All Items"
- } else if self == .running {
- "Running"
- } else if self == .latestFinished {
- "Latest Finished"
- } else {
- "Custom"
- }
- }
-
- var isActive: Bool {
- isEmpty == false
- }
-
- var allowsJobReordering: Bool {
- self == .all || contains(.running)
- }
-
- var persistedValue: String {
- guard isActive else {
- return "all"
- }
- return Self.menuFilters.compactMap { filter in
- contains(filter) ? filter.persistedSingleValue : nil
- }.joined(separator: ",")
- }
-
- init?(persistedValue: String) {
- if persistedValue == "all" {
- self = .all
- return
- }
-
- var filters = SidebarJobFilter(rawValue: 0)
- for component in persistedValue.split(separator: ",") {
- switch component.trimmingCharacters(in: .whitespacesAndNewlines) {
- case "running":
- filters.insert(.running)
- case "latestFinished":
- filters.insert(.latestFinished)
- default:
- return nil
- }
- }
- guard filters.isActive else {
- return nil
- }
- self = filters
- }
-
- private var persistedSingleValue: String? {
- if self == .running {
- return "running"
- }
- if self == .latestFinished {
- return "latestFinished"
- }
- return nil
- }
-}
diff --git a/Sources/ReviewUI/ReviewMonitorWindowController.swift b/Sources/ReviewUI/ReviewMonitorWindowController.swift
index 086c4107..870526bb 100644
--- a/Sources/ReviewUI/ReviewMonitorWindowController.swift
+++ b/Sources/ReviewUI/ReviewMonitorWindowController.swift
@@ -1,5 +1,6 @@
import AppKit
-import CodexReview
+import CodexKit
+import CodexReviewKit
@MainActor
func configureReviewMonitorWindowBase(_ window: NSWindow) {
@@ -21,18 +22,44 @@ public final class ReviewMonitorWindowController: NSWindowController {
public convenience init(store: CodexReviewStore) {
self.init(
store: store,
+ codexModelSource: nil,
contentTransitionAnimator: ReviewMonitorRootViewController.defaultContentTransitionAnimator,
showSettings: nil
)
}
- @_spi(PreviewSupport)
public convenience init(
store: CodexReviewStore,
+ codexModelContext: CodexModelContext
+ ) {
+ self.init(
+ store: store,
+ codexModelSource: ReviewMonitorCodexModelSource(modelContext: codexModelContext),
+ contentTransitionAnimator: ReviewMonitorRootViewController.defaultContentTransitionAnimator,
+ showSettings: nil
+ )
+ }
+
+ package convenience init(
+ store: CodexReviewStore,
+ showSettings: @escaping @MainActor () -> Void
+ ) {
+ self.init(
+ store: store,
+ codexModelSource: nil,
+ contentTransitionAnimator: ReviewMonitorRootViewController.defaultContentTransitionAnimator,
+ showSettings: showSettings
+ )
+ }
+
+ package convenience init(
+ store: CodexReviewStore,
+ codexModelSource: ReviewMonitorCodexModelSource,
showSettings: @escaping @MainActor () -> Void
) {
self.init(
store: store,
+ codexModelSource: codexModelSource,
contentTransitionAnimator: ReviewMonitorRootViewController.defaultContentTransitionAnimator,
showSettings: showSettings
)
@@ -40,36 +67,80 @@ public final class ReviewMonitorWindowController: NSWindowController {
convenience init(
store: CodexReviewStore,
+ codexModelSource: ReviewMonitorCodexModelSource? = nil,
contentTransitionAnimator: @escaping ReviewMonitorContentTransitionAnimator,
- sidebarJobFilterDefaults: UserDefaults? = .standard,
- showSettings: (@MainActor () -> Void)? = nil
+ initialSelection: ReviewMonitorSelection? = nil,
+ sidebarReviewChatFilterDefaults: UserDefaults? = .standard,
+ showSettings: (@MainActor () -> Void)? = nil,
+ dependencyRetainer: AnyObject? = nil
) {
self.init(
store: store,
+ codexModelSource: codexModelSource,
contentTransitionAnimator: contentTransitionAnimator,
+ initialSelection: initialSelection,
frameAutosaveName: Self.frameAutosaveName,
- sidebarJobFilterDefaults: sidebarJobFilterDefaults,
- showSettings: showSettings
+ sidebarReviewChatFilterDefaults: sidebarReviewChatFilterDefaults,
+ showSettings: showSettings,
+ dependencyRetainer: dependencyRetainer
)
}
- init(
+ package convenience init(
store: CodexReviewStore,
+ uiState: ReviewMonitorUIState,
+ codexModelSource: ReviewMonitorCodexModelSource? = nil,
+ showSettings: (@MainActor () -> Void)? = nil,
+ dependencyRetainer: AnyObject? = nil
+ ) {
+ let rootViewController = ReviewMonitorRootViewController(
+ store: store,
+ uiState: uiState,
+ codexModelSource: codexModelSource,
+ showSettings: showSettings,
+ dependencyRetainer: dependencyRetainer
+ )
+ self.init(
+ rootViewController: rootViewController,
+ frameAutosaveName: Self.frameAutosaveName
+ )
+ }
+
+ convenience init(
+ store: CodexReviewStore,
+ codexModelSource: ReviewMonitorCodexModelSource? = nil,
contentTransitionAnimator: @escaping ReviewMonitorContentTransitionAnimator,
+ initialSelection: ReviewMonitorSelection? = nil,
frameAutosaveName: NSWindow.FrameAutosaveName,
- sidebarJobFilterDefaults: UserDefaults? = .standard,
- showSettings: (@MainActor () -> Void)? = nil
+ sidebarReviewChatFilterDefaults: UserDefaults? = .standard,
+ showSettings: (@MainActor () -> Void)? = nil,
+ dependencyRetainer: AnyObject? = nil
) {
let uiState = Self.makeUIState(
auth: store.auth,
- sidebarJobFilterDefaults: sidebarJobFilterDefaults
+ sidebarReviewChatFilterDefaults: sidebarReviewChatFilterDefaults
)
+ if uiState.selection == nil {
+ uiState.selection = initialSelection
+ }
let rootViewController = ReviewMonitorRootViewController(
store: store,
uiState: uiState,
+ codexModelSource: codexModelSource,
contentTransitionAnimator: contentTransitionAnimator,
- showSettings: showSettings
+ showSettings: showSettings,
+ dependencyRetainer: dependencyRetainer
)
+ self.init(
+ rootViewController: rootViewController,
+ frameAutosaveName: frameAutosaveName
+ )
+ }
+
+ private init(
+ rootViewController: ReviewMonitorRootViewController,
+ frameAutosaveName: NSWindow.FrameAutosaveName
+ ) {
let window = NSWindow(
contentRect: NSRect(origin: .zero, size: Self.defaultContentSize),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
@@ -94,16 +165,16 @@ public final class ReviewMonitorWindowController: NSWindowController {
private static func makeUIState(
auth: CodexReviewAuthModel,
- sidebarJobFilterDefaults: UserDefaults?
+ sidebarReviewChatFilterDefaults: UserDefaults?
) -> ReviewMonitorUIState {
- guard let sidebarJobFilterDefaults else {
+ guard let sidebarReviewChatFilterDefaults else {
return ReviewMonitorUIState(auth: auth)
}
return ReviewMonitorUIState(
auth: auth,
- sidebarJobFilter: ReviewMonitorSidebar.JobFilterPersistence.load(from: sidebarJobFilterDefaults),
- persistSidebarJobFilter: { filter in
- ReviewMonitorSidebar.JobFilterPersistence.save(filter, to: sidebarJobFilterDefaults)
+ sidebarReviewChatFilter: ReviewMonitorSidebar.ReviewChatFilterPersistence.load(from: sidebarReviewChatFilterDefaults),
+ persistSidebarReviewChatFilter: { filter in
+ ReviewMonitorSidebar.ReviewChatFilterPersistence.save(filter, to: sidebarReviewChatFilterDefaults)
}
)
}
@@ -112,20 +183,20 @@ public final class ReviewMonitorWindowController: NSWindowController {
enum ReviewMonitorSidebar {}
extension ReviewMonitorSidebar {
-enum JobFilterPersistence {
- static let defaultsKey = "CodexReviewKit.ReviewMonitor.sidebarJobFilter"
-
- static func load(from defaults: UserDefaults) -> SidebarJobFilter {
- guard let rawValue = defaults.string(forKey: defaultsKey),
- let filter = SidebarJobFilter(persistedValue: rawValue)
- else {
- return .all
+ enum ReviewChatFilterPersistence {
+ static let defaultsKey = "CodexReviewKit.ReviewMonitor.sidebarReviewChatFilter"
+
+ static func load(from defaults: UserDefaults) -> SidebarReviewChatFilter {
+ guard let rawValue = defaults.string(forKey: defaultsKey),
+ let filter = SidebarReviewChatFilter(persistedValue: rawValue)
+ else {
+ return .all
+ }
+ return filter
}
- return filter
- }
- static func save(_ filter: SidebarJobFilter, to defaults: UserDefaults) {
- defaults.set(filter.persistedValue, forKey: defaultsKey)
+ static func save(_ filter: SidebarReviewChatFilter, to defaults: UserDefaults) {
+ defaults.set(filter.persistedValue, forKey: defaultsKey)
+ }
}
}
-}
diff --git a/Sources/ReviewUI/ReviewMonitorWorkspaceGrouping.swift b/Sources/ReviewUI/ReviewMonitorWorkspaceGrouping.swift
deleted file mode 100644
index d84b85c2..00000000
--- a/Sources/ReviewUI/ReviewMonitorWorkspaceGrouping.swift
+++ /dev/null
@@ -1,150 +0,0 @@
-import Foundation
-
-struct ReviewMonitorWorkspaceSectionSelection: Hashable, Sendable {
- var id: String
- var title: String
- var workspaceCWDs: [String]
-
- var subtitle: String {
- workspaceCWDs.count == 1 ? (workspaceCWDs.first ?? "") : "\(workspaceCWDs.count) workspaces"
- }
-}
-
-struct ReviewMonitorWorkspaceSectionIdentity: Hashable, Sendable {
- var id: String
- var title: String
-}
-
-enum ReviewMonitorWorkspaceSectioning {
- static func identity(for cwd: String, fileManager: FileManager = .default) -> ReviewMonitorWorkspaceSectionIdentity {
- let cwdURL = standardizedDirectoryURL(cwd)
- guard let gitMetadataURL = enclosingGitMetadataURL(startingAt: cwdURL, fileManager: fileManager) else {
- return ReviewMonitorWorkspaceSectionIdentity(
- id: "cwd:\(cwdURL.path)",
- title: fallbackTitle(for: cwdURL)
- )
- }
-
- var isDirectory: ObjCBool = false
- let gitMetadataPath = gitMetadataURL.path
- guard fileManager.fileExists(atPath: gitMetadataPath, isDirectory: &isDirectory) else {
- return ReviewMonitorWorkspaceSectionIdentity(
- id: "cwd:\(cwdURL.path)",
- title: fallbackTitle(for: cwdURL)
- )
- }
-
- 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 ReviewMonitorWorkspaceSectionIdentity(
- id: "cwd:\(cwdURL.path)",
- title: fallbackTitle(for: cwdURL)
- )
- }
-
- let standardizedCommonDirURL = commonDirURL.standardizedFileURL.resolvingSymlinksInPath()
- return ReviewMonitorWorkspaceSectionIdentity(
- id: "git-common:\(standardizedCommonDirURL.path)",
- title: sectionTitle(commonDirURL: standardizedCommonDirURL, gitRootURL: gitRootURL, fallbackURL: cwdURL)
- )
- }
-
- private static func enclosingGitMetadataURL(startingAt cwdURL: URL, fileManager: FileManager) -> URL? {
- var directoryURL = cwdURL
- while true {
- let gitURL = directoryURL.appendingPathComponent(".git")
- if fileManager.fileExists(atPath: gitURL.path) {
- return gitURL
- }
-
- let parentURL = directoryURL.deletingLastPathComponent()
- guard parentURL.path != directoryURL.path else {
- return nil
- }
- directoryURL = parentURL
- }
- }
-
- 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 ? fallbackTitle(for: fallbackURL) : rootTitle
- }
-
- private static func fallbackTitle(for url: URL) -> String {
- let title = url.lastPathComponent
- return title.isEmpty ? url.path : title
- }
-
- private static func standardizedDirectoryURL(_ path: String) -> URL {
- URL(fileURLWithPath: path, isDirectory: true)
- .standardizedFileURL
- .resolvingSymlinksInPath()
- }
-}
diff --git a/Sources/ReviewUI/Sidebar/Accounts/AccountContextMenuView.swift b/Sources/ReviewUI/Sidebar/Accounts/AccountContextMenuView.swift
index dfee5ad3..a05525c3 100644
--- a/Sources/ReviewUI/Sidebar/Accounts/AccountContextMenuView.swift
+++ b/Sources/ReviewUI/Sidebar/Accounts/AccountContextMenuView.swift
@@ -1,9 +1,9 @@
-import CodexReview
+import CodexReviewKit
import SwiftUI
struct AccountContextMenuView: View {
let store: CodexReviewStore
- let account: CodexAccount
+ let account: CodexReviewAccount
private var auth: CodexReviewAuthModel {
store.auth
@@ -11,7 +11,7 @@ struct AccountContextMenuView: View {
private func requestDestructiveAccountAction() {
if auth.selectedAccount == account {
- store.requestSignOutActiveAccount(requiresConfirmation: store.hasRunningJobs)
+ store.requestSignOutActiveAccount(requiresConfirmation: store.hasRunningReviewRuns)
} else {
store.requestRemoveAccount(account, requiresConfirmation: false)
}
@@ -47,8 +47,8 @@ struct AccountContextMenuView: View {
#if DEBUG
#Preview {
- let currentAccount = CodexAccount(email: "current@example.com")
- let otherAccount = CodexAccount(email: "other@example.com")
+ let currentAccount = CodexReviewAccount(email: "current@example.com")
+ let otherAccount = CodexReviewAccount(email: "other@example.com")
let store: CodexReviewStore = {
let store = CodexReviewStore.makePreviewStore()
store.auth.applyPersistedAccountStates([
diff --git a/Sources/ReviewUI/Sidebar/Accounts/ReviewMonitorAccountRowView.swift b/Sources/ReviewUI/Sidebar/Accounts/ReviewMonitorAccountRowView.swift
index 1f2d348b..b4fb53b9 100644
--- a/Sources/ReviewUI/Sidebar/Accounts/ReviewMonitorAccountRowView.swift
+++ b/Sources/ReviewUI/Sidebar/Accounts/ReviewMonitorAccountRowView.swift
@@ -1,9 +1,9 @@
-import CodexReview
+import CodexReviewKit
import SwiftUI
struct ReviewMonitorAccountRowView: View {
let store: CodexReviewStore
- var account: CodexAccount?
+ var account: CodexReviewAccount?
var body: some View {
if let account {
diff --git a/Sources/ReviewUI/Sidebar/Accounts/ReviewMonitorAccountsViewController.swift b/Sources/ReviewUI/Sidebar/Accounts/ReviewMonitorAccountsViewController.swift
index b8fe7082..33cb4045 100644
--- a/Sources/ReviewUI/Sidebar/Accounts/ReviewMonitorAccountsViewController.swift
+++ b/Sources/ReviewUI/Sidebar/Accounts/ReviewMonitorAccountsViewController.swift
@@ -1,6 +1,6 @@
import AppKit
import ObservationBridge
-import CodexReview
+import CodexReviewKit
import SwiftUI
@MainActor
@@ -68,7 +68,7 @@ final class ReviewMonitorAccountsViewController: NSViewController, NSOutlineView
store.auth
}
- private var accounts: [CodexAccount] {
+ private var accounts: [CodexReviewAccount] {
auth.accounts
}
@@ -153,20 +153,20 @@ final class ReviewMonitorAccountsViewController: NSViewController, NSOutlineView
}
private func applyAccountTopology(
- accounts: [CodexAccount],
- selectedAccount: CodexAccount?
+ accounts: [CodexReviewAccount],
+ selectedAccount: CodexReviewAccount?
) {
applyAccountMembershipChange(accounts)
reconcileSelection(selectedAccount: selectedAccount, accounts: accounts)
}
- private func displayedAccounts() -> [CodexAccount] {
+ private func displayedAccounts() -> [CodexReviewAccount] {
(0.. Bool {
+ private func hasSameIdentityOrder(_ lhs: [CodexReviewAccount], _ rhs: [CodexReviewAccount]) -> Bool {
lhs.count == rhs.count &&
zip(lhs, rhs).allSatisfy { left, right in
left === right
@@ -246,13 +246,13 @@ final class ReviewMonitorAccountsViewController: NSViewController, NSOutlineView
reconcileSelection(selectedAccount: auth.selectedAccount)
}
- private func reconcileSelection(selectedAccount: CodexAccount?) {
+ private func reconcileSelection(selectedAccount: CodexReviewAccount?) {
reconcileSelection(selectedAccount: selectedAccount, accounts: accounts)
}
private func reconcileSelection(
- selectedAccount: CodexAccount?,
- accounts: [CodexAccount]
+ selectedAccount: CodexReviewAccount?,
+ accounts: [CodexReviewAccount]
) {
guard let selectedAccount,
let row = row(forAccountKey: selectedAccount.accountKey, accounts: accounts)
@@ -403,11 +403,11 @@ final class ReviewMonitorAccountsViewController: NSViewController, NSOutlineView
)
}
- private func account(from item: Any?) -> CodexAccount? {
- item as? CodexAccount
+ private func account(from item: Any?) -> CodexReviewAccount? {
+ item as? CodexReviewAccount
}
- private func account(atRow row: Int) -> CodexAccount? {
+ private func account(atRow row: Int) -> CodexReviewAccount? {
guard row >= 0,
let item = outlineView.item(atRow: row)
else {
@@ -416,7 +416,7 @@ final class ReviewMonitorAccountsViewController: NSViewController, NSOutlineView
return account(from: item)
}
- private func row(for account: CodexAccount) -> Int? {
+ private func row(for account: CodexReviewAccount) -> Int? {
row(forAccountKey: account.accountKey)
}
@@ -426,7 +426,7 @@ final class ReviewMonitorAccountsViewController: NSViewController, NSOutlineView
private func row(
forAccountKey accountKey: String,
- accounts: [CodexAccount]
+ accounts: [CodexReviewAccount]
) -> Int? {
guard let account = accounts.first(where: { $0.accountKey == accountKey }) else {
return nil
@@ -487,7 +487,7 @@ final class ReviewMonitorAccountsViewController: NSViewController, NSOutlineView
return max(0, min(adjustedDropIndex, persistedCount - 1))
}
- private func pasteboardWriter(for account: CodexAccount) -> (any NSPasteboardWriting)? {
+ private func pasteboardWriter(for account: CodexReviewAccount) -> (any NSPasteboardWriting)? {
guard auth.persistedAccounts.count > 1,
persistedAccountIndex(accountKey: account.accountKey) != nil
else {
@@ -633,7 +633,7 @@ extension ReviewMonitorAccountsViewController {
return account(atRow: outlineView.selectedRow)?.email
}
- func selectAccountRowForTesting(_ account: CodexAccount) {
+ func selectAccountRowForTesting(_ account: CodexReviewAccount) {
guard let row = row(for: account) else {
preconditionFailure("Account row is not visible.")
}
@@ -653,7 +653,7 @@ extension ReviewMonitorAccountsViewController {
}
func presentContextMenuForTesting(
- for account: CodexAccount,
+ for account: CodexReviewAccount,
presenter: @escaping (NSMenu) -> Void
) {
view.layoutSubtreeIfNeeded()
@@ -665,7 +665,7 @@ extension ReviewMonitorAccountsViewController {
outlineView.presentContextMenuForTesting(at: point, presenter: presenter)
}
- func accountRowUsesReviewMonitorAccountCellViewForTesting(_ account: CodexAccount) -> Bool {
+ func accountRowUsesReviewMonitorAccountCellViewForTesting(_ account: CodexReviewAccount) -> Bool {
guard let row = row(for: account),
outlineView.view(
atColumn: 0,
@@ -678,7 +678,7 @@ extension ReviewMonitorAccountsViewController {
return true
}
- func accountRowUsesSwiftUIRowViewForTesting(_ account: CodexAccount) -> Bool {
+ func accountRowUsesSwiftUIRowViewForTesting(_ account: CodexReviewAccount) -> Bool {
guard let row = row(for: account),
let cellView = outlineView.view(
atColumn: 0,
@@ -691,7 +691,7 @@ extension ReviewMonitorAccountsViewController {
return cellView.isHostingReviewMonitorAccountRowViewForTesting
}
- func dragPasteboardAccountKeyForTesting(_ account: CodexAccount) -> String? {
+ func dragPasteboardAccountKeyForTesting(_ account: CodexReviewAccount) -> String? {
guard let row = row(for: account) else {
preconditionFailure("Account row is not visible.")
}
@@ -711,7 +711,7 @@ extension ReviewMonitorAccountsViewController {
outlineView.mouseDown(with: mouseEventForTesting(at: point))
}
- func allowsUserSelectionForTesting(_ account: CodexAccount) -> Bool {
+ func allowsUserSelectionForTesting(_ account: CodexReviewAccount) -> Bool {
guard let row = row(for: account) else {
preconditionFailure("Account row is not visible.")
}
@@ -756,7 +756,7 @@ extension ReviewMonitorAccountsViewController {
@discardableResult
func performAccountDropForTesting(
- _ account: CodexAccount,
+ _ account: CodexReviewAccount,
proposedChildIndex index: Int
) async -> Bool {
await performAccountDropForTesting(
@@ -768,7 +768,7 @@ extension ReviewMonitorAccountsViewController {
@discardableResult
func performAccountDropForTesting(
- _ account: CodexAccount,
+ _ account: CodexReviewAccount,
proposedItem item: Any?,
proposedChildIndex index: Int
) async -> Bool {
@@ -1006,7 +1006,7 @@ private final class ReviewMonitorAccountCellView: NSTableCellView {
nil
}
- func configure(account: CodexAccount) {
+ func configure(account: CodexReviewAccount) {
objectValue = account
toolTip = account.email
hostingView.rootView.account = account
diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift
new file mode 100644
index 00000000..5292e4ee
--- /dev/null
+++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift
@@ -0,0 +1,97 @@
+import AppKit
+import CodexKit
+import CodexReviewKit
+import SwiftUI
+
+@MainActor
+struct ReviewMonitorChatArchiveConfirmation: Sendable {
+ typealias Action = @MainActor @Sendable (_ chatID: CodexThreadID, _ title: String) async -> Bool
+
+ private let action: Action
+
+ init(action: @escaping Action) {
+ self.action = action
+ }
+
+ func shouldArchive(chatID: CodexThreadID, title: String) async -> Bool {
+ await action(chatID, title)
+ }
+
+ static let appKitAlert = ReviewMonitorChatArchiveConfirmation { _, title in
+ let alert = NSAlert()
+ alert.alertStyle = .warning
+ alert.messageText = "Archive Active Chat?"
+ alert.informativeText = "\"\(title)\" is still running. Archive it?"
+ alert.addButton(withTitle: "Archive")
+ alert.addButton(withTitle: "Cancel")
+ return alert.runModal() == .alertFirstButtonReturn
+ }
+}
+
+@MainActor
+struct ReviewMonitorChatContextMenuView: View {
+ private var chat: CodexChat
+ private var store: CodexReviewStore
+ private var archiveConfirmation: ReviewMonitorChatArchiveConfirmation
+
+ init(
+ chat: CodexChat,
+ store: CodexReviewStore,
+ archiveConfirmation: ReviewMonitorChatArchiveConfirmation = .appKitAlert
+ ) {
+ self.chat = chat
+ self.store = store
+ self.archiveConfirmation = archiveConfirmation
+ }
+
+ var body: some View {
+ Button("Cancel") {
+ cancel()
+ }
+ .disabled(cancellationCapability.isEnabled == false)
+
+ Divider()
+
+ Button("Archive") {
+ archive()
+ }
+ }
+
+ private func cancel() {
+ let chatID = chat.id.rawValue
+ let action = cancellationCapability.action
+ Task {
+ switch action {
+ case .some(.reviewRun):
+ _ = try? await store.cancelReview(chatID: chatID, cancellation: .userInterface())
+ case .some(.directChat):
+ _ = try? await chat.cancel()
+ case nil:
+ break
+ }
+ }
+ }
+
+ private func archive() {
+ let archiveConfirmation = archiveConfirmation
+ Task { @MainActor in
+ if chat.status?.isActive == true {
+ let shouldArchive = await archiveConfirmation.shouldArchive(
+ chatID: chat.id,
+ title: chat.title
+ )
+ guard shouldArchive else {
+ return
+ }
+ }
+ try? await chat.archive()
+ }
+ }
+
+ private var cancellationCapability: CodexChatCancellationCapability {
+ store.chatCancellationCapability(
+ forChatID: chat.id.rawValue,
+ isChatActive: chat.status?.isActive == true
+ )
+ }
+}
diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift
new file mode 100644
index 00000000..f7060d03
--- /dev/null
+++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift
@@ -0,0 +1,123 @@
+import Foundation
+import SwiftUI
+import CodexKit
+
+@MainActor
+struct ReviewMonitorChatRowView: View {
+ var chat: CodexChat
+
+ var body: some View {
+ let isRunning = chat.status?.isActive == true
+ let startedAt = isRunning ? chat.activityDate : nil
+
+ Label {
+ VStack {
+ HStack {
+ Text(chat.title)
+ .truncationMode(.tail)
+ Spacer(minLength: 0)
+ if let startedAt {
+ Text(
+ timerInterval: startedAt...(.distantFuture),
+ pauseTime: nil,
+ countsDown: false,
+ showsHours: true
+ )
+ .monospacedDigit()
+ .foregroundStyle(.secondary)
+ .layoutPriority(1)
+ }
+ }
+ .lineLimit(1)
+ HStack {
+ Text(chat.modelProvider?.trimmedNonEmpty ?? "")
+ Text(chat.preview?.trimmedNonEmpty ?? "")
+ Spacer(minLength: 0)
+ }
+ .textScale(.secondary)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ } icon: {
+ ZStack {
+ Image(systemName: "circle.fill")
+ .foregroundStyle(.clear)
+ if isRunning {
+ ProgressView()
+ .controlSize(.mini)
+ }
+ }
+ .animation(.default, value: isRunning)
+ .padding(.leading, SidebarLayout.disclosureGutterWidth)
+ }
+ .transaction(value: chat.id.rawValue) { transaction in
+ transaction.disablesAnimations = true
+ }
+ }
+}
+
+private extension String {
+ var trimmedNonEmpty: String? {
+ let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.isEmpty ? nil : trimmed
+ }
+}
+
+private extension CodexChat {
+ var activityDate: Date? {
+ recencyAt ?? updatedAt
+ }
+}
+
+@MainActor
+package func measuredReviewMonitorChatRowHeight() -> CGFloat {
+ ReviewMonitorChatRowView.measureMeasuredHeight()
+}
+
+@MainActor
+extension ReviewMonitorChatRowView {
+ static func measureMeasuredHeight() -> CGFloat {
+ let hostingView = NSHostingView(
+ rootView: Label {
+ VStack {
+ HStack {
+ Text("Uncommitted changes")
+ .truncationMode(.tail)
+ Spacer(minLength: 0)
+ Text(
+ timerInterval: Date(timeIntervalSince1970: 0)...(.distantFuture),
+ pauseTime: nil,
+ countsDown: false,
+ showsHours: true
+ )
+ .monospacedDigit()
+ .foregroundStyle(.secondary)
+ .layoutPriority(1)
+ }
+ .lineLimit(1)
+ HStack {
+ Text("gpt-5.5")
+ Text("Review output preview")
+ Spacer(minLength: 0)
+ }
+ .textScale(.secondary)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ } icon: {
+ ZStack {
+ Image(systemName: "circle.fill")
+ .foregroundStyle(.clear)
+ ProgressView()
+ .controlSize(.mini)
+ }
+ .animation(.default, value: true)
+ .padding(.leading, SidebarLayout.disclosureGutterWidth)
+ }
+ .transaction(value: "row-height-measurement") { transaction in
+ transaction.disablesAnimations = true
+ }
+ )
+ return ceil(hostingView.fittingSize.height)
+ }
+}
diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift
new file mode 100644
index 00000000..b2d52f30
--- /dev/null
+++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift
@@ -0,0 +1,487 @@
+import CodexKit
+import CodexReviewKit
+import Foundation
+
+package struct ReviewMonitorCodexSidebarRowID: Hashable, Sendable, CustomStringConvertible {
+ package var rawValue: String
+
+ package init(rawValue: String) {
+ self.rawValue = rawValue
+ }
+
+ package static func workspaceGroup(_ id: CodexWorkspaceGroupID) -> Self {
+ .init(rawValue: "workspaceGroup:\(id.rawValue)")
+ }
+
+ package static func section(_ id: CodexFetchSectionID) -> Self {
+ switch id {
+ case .default:
+ .init(rawValue: "section:default")
+ case .workspaceGroup(let id):
+ .init(rawValue: "section:workspaceGroup:\(id.rawValue)")
+ case .workspace(let id):
+ .init(rawValue: "section:workspace:\(id.rawValue)")
+ case .unknown(let rawValue):
+ .init(rawValue: "section:unknown:\(rawValue)")
+ }
+ }
+
+ package static func chat(_ id: CodexThreadID) -> Self {
+ .init(rawValue: "chat:\(id.rawValue)")
+ }
+
+ package var description: String {
+ rawValue
+ }
+}
+
+@MainActor
+extension CodexFetchSection where Model == CodexChat {
+ package var sidebarWorkspaceGroupID: CodexWorkspaceGroupID? {
+ workspaceGroupID
+ }
+
+ package var displayTitle: String {
+ workspaceGroup?.name ?? title ?? "Unknown"
+ }
+
+ package var rowID: ReviewMonitorCodexSidebarRowID {
+ if let sidebarWorkspaceGroupID {
+ return .workspaceGroup(sidebarWorkspaceGroupID)
+ }
+ return .section(id)
+ }
+
+ package var rowIDs: [ReviewMonitorCodexSidebarRowID] {
+ return [rowID] + items.map { .chat($0.id) }
+ }
+}
+
+@MainActor
+extension Array where Element == CodexFetchSection {
+ var rowIDs: [ReviewMonitorCodexSidebarRowID] {
+ flatMap(\.rowIDs)
+ }
+
+ func chat(id: CodexThreadID) -> CodexChat? {
+ for section in self {
+ if let chat = section.chat(id: id) {
+ return chat
+ }
+ }
+ return nil
+ }
+
+ func filtered(by filter: SidebarReviewChatFilter) -> [CodexFetchSection] {
+ guard filter.isActive else {
+ return self
+ }
+
+ return compactMap { section in
+ let latestFinishedChatID =
+ filter.contains(.latestFinished)
+ ? Self.latestFinishedChat(in: section.items)?.id
+ : nil
+ let items = section.items.filter {
+ Self.includes($0, filter: filter, latestFinishedChatID: latestFinishedChatID)
+ }
+ guard items.isEmpty == false else {
+ return nil
+ }
+ return CodexFetchSection(
+ id: section.id,
+ title: section.title,
+ items: items
+ )
+ }
+ }
+
+ private static func includes(
+ _ chat: CodexChat,
+ filter: SidebarReviewChatFilter,
+ latestFinishedChatID: CodexThreadID?
+ ) -> Bool {
+ if filter.contains(.running), chat.status?.isActive == true {
+ return true
+ }
+ if filter.contains(.latestFinished), chat.id == latestFinishedChatID {
+ return true
+ }
+ return false
+ }
+
+ private static func latestFinishedChat(in chats: [CodexChat]) -> CodexChat? {
+ var latestChat: CodexChat?
+ var latestDate = Date.distantPast
+ for chat in chats {
+ // Listed chats frequently omit status; anything not actively
+ // running counts as finished, matching the running classification
+ // used everywhere else in this sidebar.
+ guard chat.status?.isActive != true else {
+ continue
+ }
+ let finishedAt = chat.activityDate ?? .distantPast
+ if latestChat == nil || finishedAt > latestDate {
+ latestChat = chat
+ latestDate = finishedAt
+ }
+ }
+ return latestChat
+ }
+}
+
+private extension CodexChat {
+ var activityDate: Date? {
+ recencyAt ?? updatedAt
+ }
+}
+
+@MainActor
+struct ReviewMonitorCodexSidebarPresentationOrder: Equatable {
+ private var workspaceGroupIDs: [CodexWorkspaceGroupID] = []
+ private var chatIDsByContainer: [ReviewMonitorCodexSidebarRowID: [CodexThreadID]] = [:]
+
+ func applying(
+ to sections: [CodexFetchSection]
+ ) -> [CodexFetchSection] {
+ orderedSections(sections).map { section in
+ CodexFetchSection(
+ id: section.id,
+ title: section.title,
+ items: ordered(
+ section.items,
+ by: chatIDsByContainer[section.rowID] ?? [],
+ id: \.id
+ )
+ )
+ }
+ }
+
+ mutating func reorderWorkspaceGroup(
+ id: CodexWorkspaceGroupID,
+ currentOrder: [CodexWorkspaceGroupID],
+ before targetID: CodexWorkspaceGroupID?
+ ) -> Bool {
+ var ids = mergedOrder(preferredIDs: workspaceGroupIDs, currentOrder: currentOrder)
+ let didChange = Self.reorder(id: id, before: targetID, in: &ids)
+ workspaceGroupIDs = ids
+ return didChange
+ }
+
+ mutating func reorderChat(
+ id: CodexThreadID,
+ in container: ReviewMonitorCodexSidebarRowID,
+ currentOrder: [CodexThreadID],
+ before targetID: CodexThreadID?
+ ) -> Bool {
+ var ids = mergedOrder(preferredIDs: chatIDsByContainer[container] ?? [], currentOrder: currentOrder)
+ let didChange = Self.reorder(id: id, before: targetID, in: &ids)
+ chatIDsByContainer[container] = ids
+ return didChange
+ }
+
+ mutating func prune(to sections: [CodexFetchSection]) {
+ let activeWorkspaceGroupIDs = sections.compactMap(\.sidebarWorkspaceGroupID)
+ workspaceGroupIDs = workspaceGroupIDs.filter { activeWorkspaceGroupIDs.contains($0) }
+
+ var activeChatIDsByContainer: [ReviewMonitorCodexSidebarRowID: Set] = [:]
+ for section in sections {
+ activeChatIDsByContainer[section.rowID] = Set(section.items.map(\.id))
+ }
+ let currentChatIDsByContainer = chatIDsByContainer
+ chatIDsByContainer = currentChatIDsByContainer.reduce(into: [:]) { result, entry in
+ guard let activeChatIDs = activeChatIDsByContainer[entry.key] else {
+ return
+ }
+ let filteredIDs = entry.value.filter { activeChatIDs.contains($0) }
+ guard filteredIDs.isEmpty == false else {
+ return
+ }
+ result[entry.key] = filteredIDs
+ }
+ }
+
+ private func orderedSections(
+ _ sections: [CodexFetchSection]
+ ) -> [CodexFetchSection] {
+ guard workspaceGroupIDs.isEmpty == false else {
+ return sections
+ }
+
+ var orderedSections: [CodexFetchSection] = []
+ var segment: [CodexFetchSection] = []
+ for section in sections {
+ guard section.sidebarWorkspaceGroupID == nil else {
+ segment.append(section)
+ continue
+ }
+ orderedSections.append(contentsOf: orderedWorkspaceGroupSegment(segment))
+ segment.removeAll(keepingCapacity: true)
+ orderedSections.append(section)
+ }
+ orderedSections.append(contentsOf: orderedWorkspaceGroupSegment(segment))
+ return orderedSections
+ }
+
+ private func orderedWorkspaceGroupSegment(
+ _ segment: [CodexFetchSection]
+ ) -> [CodexFetchSection] {
+ guard segment.count > 1 else {
+ return segment
+ }
+ let currentDomainIDs = segment.compactMap(\.sidebarWorkspaceGroupID)
+ let orderedDomainIDs = mergedOrder(preferredIDs: workspaceGroupIDs, currentOrder: currentDomainIDs)
+ let sectionsByDomainID = Dictionary(
+ uniqueKeysWithValues: segment.compactMap { section in
+ section.sidebarWorkspaceGroupID.map { ($0, section) }
+ }
+ )
+ return orderedDomainIDs.compactMap { sectionsByDomainID[$0] }
+ }
+
+ private func ordered(
+ _ elements: [Element],
+ by preferredIDs: [ID],
+ id: (Element) -> ID
+ ) -> [Element] {
+ guard preferredIDs.isEmpty == false else {
+ return elements
+ }
+ let rankByID = Dictionary(uniqueKeysWithValues: preferredIDs.enumerated().map { ($0.element, $0.offset) })
+ return elements.enumerated().sorted { lhs, rhs in
+ let lhsRank = rankByID[id(lhs.element)] ?? Int.max
+ let rhsRank = rankByID[id(rhs.element)] ?? Int.max
+ if lhsRank != rhsRank {
+ return lhsRank < rhsRank
+ }
+ return lhs.offset < rhs.offset
+ }.map(\.element)
+ }
+
+ private func mergedOrder(preferredIDs: [ID], currentOrder: [ID]) -> [ID] {
+ let activeIDs = Set(currentOrder)
+ var merged = preferredIDs.filter { activeIDs.contains($0) }
+ for id in currentOrder where merged.contains(id) == false {
+ merged.append(id)
+ }
+ return merged
+ }
+
+ private static func reorder(
+ id: ID,
+ before targetID: ID?,
+ in ids: inout [ID]
+ ) -> Bool {
+ let originalIDs = ids
+ ids.removeAll { $0 == id }
+ let insertionIndex: Int
+ if let targetID,
+ let targetIndex = ids.firstIndex(of: targetID)
+ {
+ insertionIndex = targetIndex
+ } else {
+ insertionIndex = ids.count
+ }
+ ids.insert(id, at: insertionIndex)
+ return ids != originalIDs
+ }
+}
+
+@MainActor
+package enum ReviewMonitorCodexSidebarOutlineItem: Equatable {
+ case section(CodexFetchSectionID)
+ case workspaceGroup(CodexWorkspaceGroupID)
+ case chat(CodexThreadID)
+
+ package var rowID: ReviewMonitorCodexSidebarRowID {
+ switch self {
+ case .section(let id):
+ .section(id)
+ case .workspaceGroup(let id):
+ .workspaceGroup(id)
+ case .chat(let id):
+ .chat(id)
+ }
+ }
+
+ var selectionID: ReviewMonitorSelectionID? {
+ switch self {
+ case .section:
+ nil
+ case .workspaceGroup(let id):
+ .workspaceGroup(id)
+ case .chat(let id):
+ .chat(id)
+ }
+ }
+
+ var workspaceGroupID: CodexWorkspaceGroupID? {
+ switch self {
+ case .workspaceGroup(let id):
+ id
+ case .section, .chat:
+ nil
+ }
+ }
+
+ var chatID: CodexThreadID? {
+ switch self {
+ case .chat(let id):
+ id
+ case .section, .workspaceGroup:
+ nil
+ }
+ }
+
+ var isChat: Bool {
+ chatID != nil
+ }
+}
+
+@MainActor
+final class ReviewMonitorCodexSidebarOutlineTree {
+ struct ApplyResult: Equatable {
+ var topologyChanged: Bool
+ var topologyChanges: [ReviewMonitorCodexSidebarOutlineTopologyChange]
+ }
+
+ private var nodesByRowID: [ReviewMonitorCodexSidebarRowID: ReviewMonitorCodexSidebarOutlineNode] = [:]
+ private(set) var roots: [ReviewMonitorCodexSidebarOutlineNode] = []
+
+ func apply(sections: [CodexFetchSection]) -> ApplyResult {
+ let oldTopology = topology
+ var activeRowIDs: Set = []
+ roots = sections.map { sectionNode(for: $0, activeRowIDs: &activeRowIDs) }
+ nodesByRowID = nodesByRowID.filter { activeRowIDs.contains($0.key) }
+ let newTopology = topology
+ return ApplyResult(
+ topologyChanged: newTopology != oldTopology,
+ topologyChanges: Self.topologyChanges(from: oldTopology, to: newTopology)
+ )
+ }
+
+ func node(rowID: ReviewMonitorCodexSidebarRowID) -> ReviewMonitorCodexSidebarOutlineNode? {
+ nodesByRowID[rowID]
+ }
+
+ private var topology: ReviewMonitorCodexSidebarOutlineTopology {
+ ReviewMonitorCodexSidebarOutlineTopology(
+ roots: roots.map(\.rowID),
+ childrenByRowID: nodesByRowID.mapValues { node in
+ node.children.map(\.rowID)
+ }
+ )
+ }
+
+ private static func topologyChanges(
+ from oldTopology: ReviewMonitorCodexSidebarOutlineTopology,
+ to newTopology: ReviewMonitorCodexSidebarOutlineTopology
+ ) -> [ReviewMonitorCodexSidebarOutlineTopologyChange] {
+ var changes: [ReviewMonitorCodexSidebarOutlineTopologyChange] = []
+ if oldTopology.roots != newTopology.roots {
+ changes.append(
+ ReviewMonitorCodexSidebarOutlineTopologyChange(
+ parentRowID: nil,
+ oldChildRowIDs: oldTopology.roots,
+ newChildRowIDs: newTopology.roots
+ ))
+ }
+
+ let sharedParentRowIDs = Set(oldTopology.childrenByRowID.keys)
+ .intersection(newTopology.childrenByRowID.keys)
+ .sorted { $0.rawValue < $1.rawValue }
+ for parentRowID in sharedParentRowIDs {
+ let oldChildRowIDs = oldTopology.childrenByRowID[parentRowID] ?? []
+ let newChildRowIDs = newTopology.childrenByRowID[parentRowID] ?? []
+ guard oldChildRowIDs != newChildRowIDs else {
+ continue
+ }
+ changes.append(
+ ReviewMonitorCodexSidebarOutlineTopologyChange(
+ parentRowID: parentRowID,
+ oldChildRowIDs: oldChildRowIDs,
+ newChildRowIDs: newChildRowIDs
+ ))
+ }
+ return changes
+ }
+
+ private func sectionNode(
+ for section: CodexFetchSection,
+ activeRowIDs: inout Set
+ ) -> ReviewMonitorCodexSidebarOutlineNode {
+ let item: ReviewMonitorCodexSidebarOutlineItem =
+ if let workspaceGroupID = section.sidebarWorkspaceGroupID {
+ .workspaceGroup(workspaceGroupID)
+ } else {
+ .section(section.id)
+ }
+ let node = node(for: item, activeRowIDs: &activeRowIDs)
+ let children = section.items.map { chat in
+ self.node(for: .chat(chat.id), activeRowIDs: &activeRowIDs)
+ }
+ updateChildren(of: node, to: children)
+ return node
+ }
+
+ private func node(
+ for item: ReviewMonitorCodexSidebarOutlineItem,
+ activeRowIDs: inout Set
+ ) -> ReviewMonitorCodexSidebarOutlineNode {
+ activeRowIDs.insert(item.rowID)
+ let node = nodesByRowID[item.rowID] ?? ReviewMonitorCodexSidebarOutlineNode(item: item)
+ nodesByRowID[item.rowID] = node
+ if node.item != item {
+ node.item = item
+ }
+ return node
+ }
+
+ private func updateChildren(
+ of node: ReviewMonitorCodexSidebarOutlineNode,
+ to children: [ReviewMonitorCodexSidebarOutlineNode]
+ ) {
+ if node.children.map(\.rowID) != children.map(\.rowID) {
+ node.children = children
+ }
+ }
+}
+
+@MainActor
+private struct ReviewMonitorCodexSidebarOutlineTopology: Equatable {
+ var roots: [ReviewMonitorCodexSidebarRowID]
+ var childrenByRowID: [ReviewMonitorCodexSidebarRowID: [ReviewMonitorCodexSidebarRowID]]
+}
+
+@MainActor
+struct ReviewMonitorCodexSidebarOutlineTopologyChange: Equatable {
+ var parentRowID: ReviewMonitorCodexSidebarRowID?
+ var oldChildRowIDs: [ReviewMonitorCodexSidebarRowID]
+ var newChildRowIDs: [ReviewMonitorCodexSidebarRowID]
+}
+
+@MainActor
+final class ReviewMonitorCodexSidebarOutlineNode {
+ fileprivate(set) var item: ReviewMonitorCodexSidebarOutlineItem
+ fileprivate(set) var children: [ReviewMonitorCodexSidebarOutlineNode] = []
+
+ init(item: ReviewMonitorCodexSidebarOutlineItem) {
+ self.item = item
+ }
+
+ var rowID: ReviewMonitorCodexSidebarRowID {
+ item.rowID
+ }
+
+ var selectionID: ReviewMonitorSelectionID? {
+ item.selectionID
+ }
+
+ var workspaceGroupID: CodexWorkspaceGroupID? {
+ item.workspaceGroupID
+ }
+
+ var isExpandable: Bool {
+ children.isEmpty == false
+ }
+}
diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift
new file mode 100644
index 00000000..68b2c1e3
--- /dev/null
+++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift
@@ -0,0 +1,2406 @@
+import AppKit
+import CodexKit
+import ObservationBridge
+import CodexReviewKit
+import SwiftUI
+
+package enum SidebarLayout {
+ static let disclosureGutterWidth: CGFloat = 16
+}
+
+@MainActor
+final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewDataSource, NSOutlineViewDelegate {
+ enum SidebarKind: Equatable {
+ case unavailable
+ case empty
+ case chatList
+ case accountList
+ }
+
+ private enum Identifier {
+ static let tableColumn = NSUserInterfaceItemIdentifier("ReviewMonitorReviewChats.Column")
+ static let reviewChatCell = NSUserInterfaceItemIdentifier("ReviewMonitorReviewChats.ReviewChatCell")
+ static let workspaceGroupCell = NSUserInterfaceItemIdentifier("ReviewMonitorReviewChats.WorkspaceGroupCell")
+ }
+
+ private enum DragType {
+ static let sidebarItem = NSPasteboard.PasteboardType("dev.codexreviewmcp.sidebar-item")
+ }
+
+ private struct SidebarRowHeights {
+ let workspaceGroup: CGFloat
+ let reviewChat: CGFloat
+
+ @MainActor
+ static func measure() -> SidebarRowHeights {
+ SidebarRowHeights(
+ workspaceGroup: measuredWorkspaceGroupRowHeight(),
+ reviewChat: measuredReviewMonitorChatRowHeight()
+ )
+ }
+
+ @MainActor
+ private static func measuredWorkspaceGroupRowHeight() -> CGFloat {
+ let cellView = ReviewMonitorWorkspaceGroupCellView()
+ cellView.configure(title: "workspace-alpha", toolTip: "/tmp/workspace-alpha")
+ return ceil(cellView.fittingSize.height)
+ }
+ }
+
+ private enum SidebarDragPayload: Codable, Equatable {
+ case codexWorkspaceGroup(id: String)
+ case codexChat(id: String, containerRowID: String)
+ }
+
+ private struct SidebarResolvedDrop {
+ enum Operation {
+ case none
+ case reorderCodexWorkspaceGroup(
+ id: CodexWorkspaceGroupID,
+ currentOrder: [CodexWorkspaceGroupID],
+ beforeID: CodexWorkspaceGroupID?
+ )
+ case reorderCodexChat(
+ id: CodexThreadID,
+ container: ReviewMonitorCodexSidebarRowID,
+ currentOrder: [CodexThreadID],
+ beforeID: CodexThreadID?
+ )
+ }
+
+ let operation: Operation
+ let dropItem: Any?
+ let dropChildIndex: Int
+ }
+
+ private struct SidebarCodexChatDropDestination {
+ let container: ReviewMonitorCodexSidebarRowID
+ let childIndex: Int
+ let dropItem: Any?
+ let dropChildIndex: Int
+ }
+
+ private let store: CodexReviewStore
+ private let uiState: ReviewMonitorUIState
+ private let codexModelSource: ReviewMonitorCodexModelSource?
+ private let scrollView = NSScrollView()
+ private let outlineView = ReviewMonitorSidebarOutlineView()
+ private let accountsViewController: ReviewMonitorAccountsViewController
+ private let emptyStateViewController = PlaceholderViewController(content: .noReviewChats)
+ private let unavailableView: NSHostingView
+ private let rowHeights: SidebarRowHeights
+
+ private var sidebarKindObservation: PortableObservationTracking.Token?
+ private var sidebarFilterObservation: PortableObservationTracking.Token?
+ private var sidebarSelectionObservation: PortableObservationTracking.Token?
+ private var codexSidebarObservation: PortableObservationTracking.Token?
+ private var codexSidebarTransactionTask: Task?
+ private var codexSidebarFetchTask: Task?
+ private var codexSidebarResultsController: CodexFetchedResultsController?
+ private var codexSidebarModelContext: CodexModelContext?
+ private var codexSidebarPresentationOrder = ReviewMonitorCodexSidebarPresentationOrder()
+ private let codexSidebarOutlineTree = ReviewMonitorCodexSidebarOutlineTree()
+ private var chatArchiveConfirmation: ReviewMonitorChatArchiveConfirmation = .appKitAlert
+ private var appliedSidebarKind: SidebarKind?
+ private var isReconcilingSelection = false
+ #if DEBUG
+ private var fullReloadCountForTesting = 0
+ #endif
+
+ init(
+ store: CodexReviewStore,
+ uiState: ReviewMonitorUIState,
+ codexModelSource: ReviewMonitorCodexModelSource? = nil
+ ) {
+ self.store = store
+ self.uiState = uiState
+ self.codexModelSource = codexModelSource
+ self.accountsViewController = ReviewMonitorAccountsViewController(store: store)
+ self.unavailableView = NSHostingView(rootView: MCPServerUnavailableView(store: store))
+ self.rowHeights = SidebarRowHeights.measure()
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ nil
+ }
+
+ isolated deinit {
+ sidebarKindObservation?.cancel()
+ sidebarFilterObservation?.cancel()
+ sidebarSelectionObservation?.cancel()
+ codexSidebarObservation?.cancel()
+ codexSidebarTransactionTask?.cancel()
+ codexSidebarFetchTask?.cancel()
+ }
+
+ override func loadView() {
+ view = NSView(frame: .zero)
+ }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ configureHierarchy()
+ configureOutlineView()
+ applyCodexSidebarSourceSections([])
+ bindObservation()
+ bindCodexSidebarFetchedResults()
+ }
+
+ override func viewDidLayout() {
+ super.viewDidLayout()
+ }
+
+ private func configureHierarchy() {
+ scrollView.translatesAutoresizingMaskIntoConstraints = false
+ scrollView.drawsBackground = false
+ scrollView.borderType = .noBorder
+ scrollView.hasVerticalScroller = true
+ scrollView.autohidesScrollers = true
+
+ view.addSubview(scrollView)
+ addChild(emptyStateViewController)
+ emptyStateViewController.view.translatesAutoresizingMaskIntoConstraints = false
+ emptyStateViewController.view.isHidden = true
+ view.addSubview(emptyStateViewController.view)
+ addChild(accountsViewController)
+ accountsViewController.view.translatesAutoresizingMaskIntoConstraints = false
+ accountsViewController.view.isHidden = true
+ view.addSubview(accountsViewController.view)
+ unavailableView.translatesAutoresizingMaskIntoConstraints = false
+ unavailableView.isHidden = true
+ view.addSubview(unavailableView)
+
+ NSLayoutConstraint.activate([
+ scrollView.topAnchor.constraint(equalTo: view.topAnchor),
+ scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+
+ emptyStateViewController.view.topAnchor.constraint(equalTo: view.topAnchor),
+ emptyStateViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ emptyStateViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ emptyStateViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+
+ accountsViewController.view.topAnchor.constraint(equalTo: view.topAnchor),
+ accountsViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ accountsViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ accountsViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+
+ unavailableView.topAnchor.constraint(equalTo: view.topAnchor),
+ unavailableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ unavailableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ unavailableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ ])
+ }
+
+ private func configureOutlineView() {
+ let tableColumn = NSTableColumn(identifier: Identifier.tableColumn)
+ tableColumn.resizingMask = .autoresizingMask
+
+ outlineView.frame = NSRect(origin: .zero, size: scrollView.contentSize)
+ outlineView.autoresizingMask = [.width]
+ outlineView.addTableColumn(tableColumn)
+ outlineView.outlineTableColumn = tableColumn
+ outlineView.headerView = nil
+ outlineView.style = .sourceList
+ outlineView.indentationPerLevel = SidebarLayout.disclosureGutterWidth
+ outlineView.indentationMarkerFollowsCell = false
+ outlineView.rowSizeStyle = .custom
+ outlineView.rowHeight = rowHeights.reviewChat
+ outlineView.usesAutomaticRowHeights = false
+ outlineView.floatsGroupRows = false
+ outlineView.backgroundColor = .clear
+ outlineView.usesAlternatingRowBackgroundColors = false
+ outlineView.intercellSpacing = NSSize(width: 0, height: 12)
+ outlineView.allowsEmptySelection = true
+ outlineView.allowsMultipleSelection = false
+ outlineView.setAccessibilityIdentifier("review-monitor.review-chat-list")
+ outlineView.registerForDraggedTypes([DragType.sidebarItem])
+ outlineView.setDraggingSourceOperationMask(.move, forLocal: true)
+ outlineView.setDraggingSourceOperationMask([], forLocal: false)
+ outlineView.draggingDestinationFeedbackStyle = .sourceList
+ outlineView.dataSource = self
+ outlineView.delegate = self
+ outlineView.contextMenuProvider = { [weak self] point in
+ self?.makeContextMenu(at: point)
+ }
+ outlineView.draggingExitedHandler = { [weak self] in
+ self?.clearDropTarget()
+ }
+
+ scrollView.documentView = outlineView
+ }
+
+ private func bindObservation() {
+ sidebarKindObservation?.cancel()
+ sidebarFilterObservation?.cancel()
+ sidebarSelectionObservation?.cancel()
+
+ sidebarKindObservation = withPortableContinuousObservation { [weak self, uiState, store] _ in
+ let sidebarSelection = uiState.sidebarSelection
+ let serverState = store.serverState
+ guard let self else {
+ return
+ }
+ self.applySidebarKind(
+ Self.sidebarKind(
+ sidebarSelection: sidebarSelection,
+ serverState: serverState,
+ hasCodexSidebarContent: self.hasCodexSidebarContent
+ ))
+ }
+
+ sidebarFilterObservation = withPortableContinuousObservation { [weak self, uiState] event in
+ _ = uiState.sidebarReviewChatFilter
+ guard event.kind != .initial else {
+ return
+ }
+ self?.applyFilteredCodexSidebarSections()
+ }
+
+ sidebarSelectionObservation = withPortableContinuousObservation { [weak self, uiState] event in
+ let selection = uiState.selection
+ guard event.kind != .initial,
+ let self,
+ self.hasCodexSidebarContent
+ else {
+ return
+ }
+ // Keep semantic selection in this observation's dependency set.
+ _ = selection
+ self.reconcileOutlineSelection()
+ }
+ }
+
+ static var defaultCodexSidebarDescriptor: CodexFetchDescriptor {
+ CodexFetchDescriptor(
+ sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)]
+ )
+ }
+
+ private static func makeCodexSidebarFetchedResultsController(
+ modelContext: CodexModelContext
+ ) -> CodexFetchedResultsController {
+ modelContext.fetchedResultsController(
+ for: defaultCodexSidebarDescriptor,
+ sectionedBy: .workspaceGroup
+ )
+ }
+
+ private func bindCodexSidebarFetchedResults() {
+ guard let codexModelSource else {
+ return
+ }
+ codexSidebarObservation?.cancel()
+ codexSidebarObservation = withPortableContinuousObservation { [weak self, codexModelSource] _ in
+ self?.installCodexSidebarFetchedResults(modelContext: codexModelSource.modelContext)
+ }
+ }
+
+ private func installCodexSidebarFetchedResults(modelContext: CodexModelContext?) {
+ if let modelContext,
+ let codexSidebarModelContext,
+ codexSidebarModelContext === modelContext,
+ codexSidebarResultsController != nil
+ {
+ return
+ }
+ codexSidebarFetchTask?.cancel()
+ codexSidebarFetchTask = nil
+ codexSidebarTransactionTask?.cancel()
+ codexSidebarTransactionTask = nil
+ guard let modelContext else {
+ codexSidebarModelContext = nil
+ codexSidebarResultsController = nil
+ applyCodexSidebarSourceSections([])
+ return
+ }
+
+ let resultsController = Self.makeCodexSidebarFetchedResultsController(modelContext: modelContext)
+ codexSidebarModelContext = modelContext
+ codexSidebarResultsController = resultsController
+ codexSidebarTransactionTask = Task { @MainActor [weak self, resultsController] in
+ for await transaction in resultsController.transactions {
+ guard let self,
+ self.codexSidebarResultsController === resultsController
+ else {
+ return
+ }
+ self.applyCodexSidebarTransaction(transaction, controller: resultsController)
+ }
+ }
+ codexSidebarFetchTask = Task { @MainActor [weak self, resultsController] in
+ do {
+ try await resultsController.performFetch()
+ } catch is CancellationError {
+ } catch {
+ }
+ guard self?.codexSidebarResultsController === resultsController
+ else {
+ return
+ }
+ self?.applyCodexSidebarSourceSections(resultsController.sections)
+ self?.codexSidebarFetchTask = nil
+ }
+ }
+
+ private func applyCodexSidebarTransaction(
+ _ transaction: CodexFetchedResultsTransaction,
+ controller: CodexFetchedResultsController
+ ) {
+ clearRemovedChatSelectionIfNeeded(transaction)
+ applyCodexSidebarSourceSections(controller.sections)
+ }
+
+ private func clearRemovedChatSelectionIfNeeded(
+ _ transaction: CodexFetchedResultsTransaction
+ ) {
+ guard transaction.reason == .remove,
+ case .chat(let selectedChatID) = uiState.selection,
+ transaction.itemChanges.contains(where: { change in
+ if case .delete(let itemID, _) = change {
+ return itemID == selectedChatID
+ }
+ return false
+ })
+ else {
+ return
+ }
+ uiState.selection = nil
+ }
+
+ private var codexSidebarSourceSections: [CodexFetchSection] {
+ codexSidebarResultsController?.sections ?? []
+ }
+
+ private func codexSidebarVisibleSections(
+ from sourceSections: [CodexFetchSection]
+ ) -> [CodexFetchSection] {
+ codexSidebarPresentationOrder
+ .applying(to: sourceSections.filtered(by: uiState.sidebarReviewChatFilter))
+ }
+
+ private var currentCodexSidebarVisibleSections: [CodexFetchSection] {
+ codexSidebarVisibleSections(from: codexSidebarSourceSections)
+ }
+
+ private func applyCodexSidebarSourceSections(_ sections: [CodexFetchSection]) {
+ codexSidebarPresentationOrder.prune(to: sections)
+ applyCodexSidebarVisibleSections(from: sections)
+ }
+
+ private func applyFilteredCodexSidebarSections() {
+ applyCodexSidebarVisibleSections(from: codexSidebarSourceSections)
+ }
+
+ private func applyCodexSidebarVisibleSections(
+ from sourceSections: [CodexFetchSection]
+ ) {
+ let sections = codexSidebarVisibleSections(from: sourceSections)
+ let applyResult = codexSidebarOutlineTree.apply(sections: sections)
+ applySidebarKind(sidebarKind)
+ if applyResult.topologyChanged {
+ applyCodexSidebarOutlineTopologyChanges(applyResult.topologyChanges)
+ } else {
+ reconcileOutlineSelection()
+ }
+ }
+
+ private func applyCodexSidebarOutlineTopologyChanges(
+ _ changes: [ReviewMonitorCodexSidebarOutlineTopologyChange]
+ ) {
+ guard changes.isEmpty == false else {
+ reconcileOutlineSelection()
+ return
+ }
+
+ isReconcilingSelection = true
+ var appliedIncrementally = true
+ for change in changes {
+ guard applyCodexSidebarOutlineTopologyChange(change) else {
+ appliedIncrementally = false
+ break
+ }
+ }
+ if appliedIncrementally {
+ expandCodexSidebarNodes(codexSidebarOutlineTree.roots)
+ reconcileOutlineSelection()
+ isReconcilingSelection = false
+ } else {
+ isReconcilingSelection = false
+ reloadCodexSidebarOutline()
+ }
+ }
+
+ private func applyCodexSidebarOutlineTopologyChange(
+ _ change: ReviewMonitorCodexSidebarOutlineTopologyChange
+ ) -> Bool {
+ let parentItem: Any?
+ if let parentRowID = change.parentRowID {
+ guard let parentNode = codexSidebarOutlineTree.node(rowID: parentRowID) else {
+ return false
+ }
+ parentItem = parentNode
+ } else {
+ parentItem = nil
+ }
+ if applyCodexSidebarOutlineChildDelta(change, parentItem: parentItem) {
+ return true
+ }
+ guard let parentItem else {
+ return false
+ }
+ outlineView.reloadItem(parentItem, reloadChildren: true)
+ return true
+ }
+
+ private func applyCodexSidebarOutlineChildDelta(
+ _ change: ReviewMonitorCodexSidebarOutlineTopologyChange,
+ parentItem: Any?
+ ) -> Bool {
+ let oldChildRowIDs = change.oldChildRowIDs
+ let newChildRowIDs = change.newChildRowIDs
+ guard oldChildRowIDs != newChildRowIDs else {
+ return true
+ }
+
+ let oldChildRowIDSet = Set(oldChildRowIDs)
+ let newChildRowIDSet = Set(newChildRowIDs)
+ if oldChildRowIDSet == newChildRowIDSet {
+ return moveCodexSidebarOutlineItems(
+ from: oldChildRowIDs,
+ to: newChildRowIDs,
+ parentItem: parentItem
+ )
+ }
+
+ let retainedOldChildRowIDs = oldChildRowIDs.filter { newChildRowIDSet.contains($0) }
+ let retainedNewChildRowIDs = newChildRowIDs.filter { oldChildRowIDSet.contains($0) }
+ guard retainedOldChildRowIDs == retainedNewChildRowIDs else {
+ return false
+ }
+
+ let removedIndexes = oldChildRowIDs.enumerated().compactMap { offset, rowID in
+ newChildRowIDSet.contains(rowID) ? nil : offset
+ }
+ if removedIndexes.isEmpty == false {
+ outlineView.removeItems(
+ at: IndexSet(removedIndexes),
+ inParent: parentItem,
+ withAnimation: []
+ )
+ }
+
+ let insertedIndexes = newChildRowIDs.enumerated().compactMap { offset, rowID in
+ oldChildRowIDSet.contains(rowID) ? nil : offset
+ }
+ if insertedIndexes.isEmpty == false {
+ outlineView.insertItems(
+ at: IndexSet(insertedIndexes),
+ inParent: parentItem,
+ withAnimation: []
+ )
+ }
+ return true
+ }
+
+ private func moveCodexSidebarOutlineItems(
+ from oldChildRowIDs: [ReviewMonitorCodexSidebarRowID],
+ to newChildRowIDs: [ReviewMonitorCodexSidebarRowID],
+ parentItem: Any?
+ ) -> Bool {
+ var currentChildRowIDs = oldChildRowIDs
+ for targetIndex in newChildRowIDs.indices {
+ let targetRowID = newChildRowIDs[targetIndex]
+ guard let currentIndex = currentChildRowIDs.firstIndex(of: targetRowID) else {
+ return false
+ }
+ guard currentIndex != targetIndex else {
+ continue
+ }
+ outlineView.moveItem(
+ at: currentIndex,
+ inParent: parentItem,
+ to: targetIndex,
+ inParent: parentItem
+ )
+ let movedRowID = currentChildRowIDs.remove(at: currentIndex)
+ currentChildRowIDs.insert(movedRowID, at: targetIndex)
+ }
+ return currentChildRowIDs == newChildRowIDs
+ }
+
+ private func reloadCodexSidebarOutline() {
+ #if DEBUG
+ fullReloadCountForTesting += 1
+ #endif
+ isReconcilingSelection = true
+ outlineView.reloadData()
+ expandCodexSidebarNodes(codexSidebarOutlineTree.roots)
+ reconcileOutlineSelection()
+ isReconcilingSelection = false
+ }
+
+ private func expandCodexSidebarNodes(_ nodes: [ReviewMonitorCodexSidebarOutlineNode]) {
+ for node in nodes where node.isExpandable {
+ outlineView.expandItem(node)
+ expandCodexSidebarNodes(node.children)
+ }
+ }
+
+ private var sidebarKind: SidebarKind {
+ Self.sidebarKind(
+ sidebarSelection: uiState.sidebarSelection,
+ serverState: store.serverState,
+ hasCodexSidebarContent: hasCodexSidebarContent
+ )
+ }
+
+ private static func sidebarKind(
+ sidebarSelection: SidebarPickerSelection?,
+ serverState: CodexReviewServerState,
+ hasCodexSidebarContent: Bool
+ ) -> SidebarKind {
+ if sidebarSelection == .account {
+ return .accountList
+ }
+ switch serverState {
+ case .failed, .starting, .stopped:
+ return .unavailable
+ case .running:
+ break
+ }
+ return hasCodexSidebarContent ? .chatList : .empty
+ }
+
+ private var hasCodexSidebarContent: Bool {
+ codexSidebarOutlineTree.roots.isEmpty == false
+ }
+
+ private func applySidebarKind(_ kind: SidebarKind) {
+ guard appliedSidebarKind != kind else {
+ return
+ }
+ appliedSidebarKind = kind
+ switch kind {
+ case .unavailable:
+ unavailableView.isHidden = false
+ scrollView.isHidden = true
+ emptyStateViewController.view.isHidden = true
+ accountsViewController.view.isHidden = true
+ case .empty:
+ unavailableView.isHidden = true
+ scrollView.isHidden = true
+ emptyStateViewController.view.isHidden = false
+ accountsViewController.view.isHidden = true
+ case .chatList:
+ unavailableView.isHidden = true
+ scrollView.isHidden = false
+ emptyStateViewController.view.isHidden = true
+ accountsViewController.view.isHidden = true
+ case .accountList:
+ unavailableView.isHidden = true
+ scrollView.isHidden = true
+ emptyStateViewController.view.isHidden = true
+ accountsViewController.view.isHidden = false
+ }
+ }
+
+ private func reconcileOutlineSelection() {
+ guard let selection = uiState.selection else {
+ if outlineView.selectedRow != -1 {
+ deselectOutlineSelectionForReconciliation()
+ }
+ return
+ }
+
+ switch selection {
+ case .workspaceGroup(let selectedWorkspaceGroupID):
+ guard codexWorkspaceGroupSelection(id: selectedWorkspaceGroupID) != nil,
+ let row = row(forCodexSidebarSelectionID: .workspaceGroup(selectedWorkspaceGroupID))
+ else {
+ if shouldClearMissingSemanticSelection(selection) {
+ uiState.selection = nil
+ }
+ deselectOutlineSelectionForReconciliation()
+ return
+ }
+
+ guard outlineView.selectedRow != row else {
+ return
+ }
+ selectOutlineRowForReconciliation(row)
+
+ case .chat(let selectedChatID):
+ guard currentChatSelection(id: selectedChatID) != nil else {
+ if shouldClearMissingSemanticSelection(selection) {
+ uiState.selection = nil
+ }
+ deselectOutlineSelectionForReconciliation()
+ return
+ }
+
+ guard let row = row(forCurrentChatSelectionID: selectedChatID)
+ else {
+ deselectOutlineSelectionForReconciliation()
+ return
+ }
+
+ guard outlineView.selectedRow != row else {
+ return
+ }
+ selectOutlineRowForReconciliation(row)
+
+ }
+ }
+
+ private func selectOutlineRowForReconciliation(_ row: Int) {
+ guard outlineView.selectedRow != row else {
+ return
+ }
+ let wasReconcilingSelection = isReconcilingSelection
+ isReconcilingSelection = true
+ outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
+ isReconcilingSelection = wasReconcilingSelection
+ }
+
+ private func deselectOutlineSelectionForReconciliation() {
+ guard outlineView.selectedRow != -1 else {
+ return
+ }
+ let wasReconcilingSelection = isReconcilingSelection
+ isReconcilingSelection = true
+ outlineView.deselectAll(nil)
+ isReconcilingSelection = wasReconcilingSelection
+ }
+
+ private func updateSelectionFromOutlineView(acceptingProgrammaticSelection: Bool = false) {
+ guard isReconcilingSelection == false else {
+ return
+ }
+ guard acceptingProgrammaticSelection || outlineView.consumeUserSelectionChangeIntent() else {
+ reconcileOutlineSelection()
+ return
+ }
+ guard outlineView.selectedRow != -1 else {
+ if selectionStillExists(uiState.selection) {
+ return
+ }
+ uiState.selection = nil
+ return
+ }
+ let item = outlineView.item(atRow: outlineView.selectedRow)
+ if let node = codexSidebarNode(from: item) {
+ switch node.item {
+ case .section:
+ uiState.selection = nil
+ case .workspaceGroup(let id):
+ uiState.selection = .workspaceGroup(id)
+ case .chat(let id):
+ uiState.selection = .chat(id)
+ }
+ } else {
+ uiState.selection = nil
+ }
+ }
+
+ private func makeContextMenu(at point: NSPoint) -> NSMenu? {
+ let row = outlineView.row(at: point)
+ guard row >= 0,
+ let node = codexSidebarNode(from: outlineView.item(atRow: row)),
+ case .chat(let id) = node.item,
+ let chat = codexChatSelection(id: id)
+ else {
+ return nil
+ }
+
+ return NSHostingMenu(
+ rootView: ReviewMonitorChatContextMenuView(
+ chat: chat,
+ store: store,
+ archiveConfirmation: chatArchiveConfirmation
+ )
+ )
+ }
+
+ private func codexSidebarNode(from item: Any?) -> ReviewMonitorCodexSidebarOutlineNode? {
+ item as? ReviewMonitorCodexSidebarOutlineNode
+ }
+
+ private func shouldAllowSelection(of item: Any?) -> Bool {
+ codexSidebarNode(from: item)?.selectionID != nil
+ }
+
+ private func row(forWorkspaceCWD cwd: String) -> Int? {
+ let workspaceGroupID = workspaceGroupID(forWorkspaceCWD: cwd)
+ ?? CodexWorkspaceGroupID(rawValue: cwd)
+ return row(forCodexSidebarSelectionID: .workspaceGroup(workspaceGroupID))
+ }
+
+ private func workspaceGroupID(forWorkspaceCWD cwd: String) -> CodexWorkspaceGroupID? {
+ let workspaceID = CodexWorkspaceID(rawValue: cwd)
+ for section in codexSidebarSourceSections {
+ if section.workspaces.contains(where: { $0.id == workspaceID })
+ || section.items.contains(where: { $0.workspaceID == workspaceID })
+ {
+ return section.sidebarWorkspaceGroupID
+ }
+ if let sidebarWorkspaceGroupID = section.sidebarWorkspaceGroupID,
+ sidebarWorkspaceGroupID.rawValue == cwd || sidebarWorkspaceGroupID.rawValue == "cwd:\(cwd)"
+ {
+ return sidebarWorkspaceGroupID
+ }
+ }
+ return nil
+ }
+
+ private func row(for chatID: CodexThreadID) -> Int? {
+ row(forCodexSidebarSelectionID: .chat(chatID))
+ }
+
+ private func row(forCurrentChatSelectionID chatID: CodexThreadID) -> Int? {
+ row(forCodexSidebarSelectionID: .chat(chatID))
+ }
+
+ private func row(forCodexSidebarSelectionID selectionID: ReviewMonitorSelectionID) -> Int? {
+ let rowID: ReviewMonitorCodexSidebarRowID
+ switch selectionID {
+ case .chat(let id):
+ rowID = .chat(id)
+ case .workspaceGroup(let id):
+ rowID = .workspaceGroup(id)
+ }
+ guard let node = codexSidebarOutlineTree.node(rowID: rowID) else {
+ return nil
+ }
+ let row = outlineView.row(forItem: node)
+ return row == -1 ? nil : row
+ }
+
+ private func selectionStillExists(_ selection: ReviewMonitorSelection?) -> Bool {
+ guard let selection else {
+ return true
+ }
+ return shouldClearMissingSemanticSelection(selection) == false
+ }
+
+ private func shouldClearMissingSemanticSelection(_ selection: ReviewMonitorSelection) -> Bool {
+ guard let codexSidebarModelContext else {
+ return false
+ }
+ switch selection {
+ case .workspaceGroup(let id):
+ return codexSidebarModelContext.registeredModel(for: id) == nil
+ case .chat(let id):
+ if currentChatSelection(id: id) != nil {
+ return false
+ }
+ guard let chat = codexSidebarModelContext.registeredModel(for: id) else {
+ return false
+ }
+ return chat.isArchived
+ }
+ }
+
+ private func currentChatSelection(id: CodexThreadID) -> CodexChat? {
+ codexSidebarSourceSections.chat(id: id)
+ }
+
+ private func displayedCodexSidebarSection(id: CodexWorkspaceGroupID) -> CodexFetchSection? {
+ currentCodexSidebarVisibleSections.first { $0.sidebarWorkspaceGroupID == id }
+ }
+
+ private func displayedCodexSidebarSection(sectionID: CodexFetchSectionID) -> CodexFetchSection? {
+ currentCodexSidebarVisibleSections.first { $0.id == sectionID }
+ }
+
+ private func displayedCodexChat(id: CodexThreadID) -> CodexChat? {
+ currentCodexSidebarVisibleSections.chat(id: id)
+ ?? currentChatSelection(id: id)
+ }
+
+ private func codexSidebarTitle(for node: ReviewMonitorCodexSidebarOutlineNode) -> String? {
+ switch node.item {
+ case .section(let id):
+ return displayedCodexSidebarSection(sectionID: id)?.displayTitle
+ ?? codexSidebarSection(sectionID: id)?.displayTitle
+ case .workspaceGroup(let id):
+ return displayedCodexSidebarSection(id: id)?.displayTitle
+ ?? codexWorkspaceGroupSection(id: id)?.displayTitle
+ case .chat(let id):
+ return displayedCodexChat(id: id)?.title
+ }
+ }
+
+ private func codexWorkspaceGroupSelection(
+ id: CodexWorkspaceGroupID
+ ) -> ReviewMonitorCodexSidebarOutlineNode? {
+ guard let node = codexSidebarOutlineTree.node(rowID: .workspaceGroup(id)) else {
+ return nil
+ }
+ switch node.item {
+ case .workspaceGroup(let workspaceGroupID) where workspaceGroupID == id:
+ return node
+ case .section, .chat:
+ return nil
+ case .workspaceGroup:
+ return nil
+ }
+ }
+
+ private func codexWorkspaceGroupSection(
+ id: CodexWorkspaceGroupID
+ ) -> CodexFetchSection? {
+ codexSidebarSourceSections.first { $0.sidebarWorkspaceGroupID == id }
+ }
+
+ private func codexSidebarSection(
+ sectionID: CodexFetchSectionID
+ ) -> CodexFetchSection? {
+ codexSidebarSourceSections.first { $0.id == sectionID }
+ }
+
+ private func codexChatSelection(id: CodexThreadID) -> CodexChat? {
+ guard let node = codexSidebarOutlineTree.node(rowID: .chat(id)),
+ case .chat(let chatID) = node.item,
+ chatID == id
+ else {
+ return nil
+ }
+ return displayedCodexChat(id: id)
+ }
+
+ private func dragPayload(for item: Any) -> SidebarDragPayload? {
+ guard let node = codexSidebarNode(from: item) else {
+ return nil
+ }
+ switch node.item {
+ case .section:
+ return nil
+ case .workspaceGroup(let id):
+ return .codexWorkspaceGroup(id: id.rawValue)
+ case .chat(let id):
+ guard let parent = outlineView.parent(forItem: node) as? ReviewMonitorCodexSidebarOutlineNode else {
+ return nil
+ }
+ return .codexChat(id: id.rawValue, containerRowID: parent.rowID.rawValue)
+ }
+ }
+
+ private func makePasteboardItem(for payload: SidebarDragPayload) -> NSPasteboardItem? {
+ guard let data = try? JSONEncoder().encode(payload) else {
+ return nil
+ }
+ let item = NSPasteboardItem()
+ item.setData(data, forType: DragType.sidebarItem)
+ return item
+ }
+
+ private func dragPayload(from draggingInfo: any NSDraggingInfo) -> SidebarDragPayload? {
+ guard let draggingSource = draggingInfo.draggingSource as? NSOutlineView,
+ draggingSource === outlineView,
+ let data = draggingInfo.draggingPasteboard.data(forType: DragType.sidebarItem)
+ else {
+ return nil
+ }
+ return try? JSONDecoder().decode(SidebarDragPayload.self, from: data)
+ }
+
+ private func clearDropTarget() {
+ outlineView.setDropItem(nil, dropChildIndex: NSOutlineViewDropOnItemIndex)
+ }
+
+ private func resolvedDrop(
+ for payload: SidebarDragPayload,
+ draggingLocation: NSPoint? = nil,
+ proposedItem: Any?,
+ proposedChildIndex index: Int
+ ) -> SidebarResolvedDrop? {
+ switch payload {
+ case .codexWorkspaceGroup(let id):
+ resolvedCodexWorkspaceGroupDrop(
+ id: CodexWorkspaceGroupID(rawValue: id),
+ draggingLocation: draggingLocation,
+ proposedItem: proposedItem,
+ proposedChildIndex: index
+ )
+ case .codexChat(let id, let containerRowID):
+ resolvedCodexChatDrop(
+ id: CodexThreadID(rawValue: id),
+ container: ReviewMonitorCodexSidebarRowID(rawValue: containerRowID),
+ draggingLocation: draggingLocation,
+ proposedItem: proposedItem,
+ proposedChildIndex: index
+ )
+ }
+ }
+
+ private func resolvedCodexWorkspaceGroupDrop(
+ id: CodexWorkspaceGroupID,
+ draggingLocation: NSPoint?,
+ proposedItem: Any?,
+ proposedChildIndex index: Int
+ ) -> SidebarResolvedDrop? {
+ guard let sourceNode = codexSidebarOutlineTree.node(rowID: .workspaceGroup(id)),
+ case .workspaceGroup = sourceNode.item,
+ let sourceIndex = codexRootIndex(for: sourceNode),
+ let destinationIndex = codexRootInsertionIndex(
+ draggingLocation: draggingLocation,
+ proposedItem: proposedItem,
+ proposedChildIndex: index
+ )
+ else {
+ return nil
+ }
+
+ let clampedDestinationIndex = max(0, min(destinationIndex, codexSidebarOutlineTree.roots.count))
+ let sourceSegment = codexWorkspaceGroupRootSegment(containing: sourceIndex)
+ guard (sourceSegment.lowerBound...sourceSegment.upperBound).contains(clampedDestinationIndex) else {
+ return nil
+ }
+ let displayDestinationIndex =
+ clampedDestinationIndex > sourceIndex
+ ? clampedDestinationIndex - 1
+ : clampedDestinationIndex
+ guard displayDestinationIndex != sourceIndex else {
+ return nil
+ }
+
+ let remainingRoots = codexSidebarOutlineTree.roots.filter { $0 !== sourceNode }
+ let visibleBeforeID = remainingRoots.dropFirst(displayDestinationIndex).compactMap(\.workspaceGroupID).first
+ // Reorder against the unfiltered group order so groups hidden by an
+ // active chat filter keep their relative positions, mirroring the
+ // chat reorder path.
+ let currentOrder = codexUnfilteredWorkspaceGroupIDs()
+ let beforeID = visibleBeforeID ?? codexWorkspaceGroupBeforeID(
+ movingID: id,
+ visibleOrder: remainingRoots.compactMap(\.workspaceGroupID),
+ currentOrder: currentOrder
+ )
+
+ return SidebarResolvedDrop(
+ operation: .reorderCodexWorkspaceGroup(
+ id: id,
+ currentOrder: currentOrder,
+ beforeID: beforeID
+ ),
+ dropItem: nil,
+ dropChildIndex: clampedDestinationIndex
+ )
+ }
+
+ private func codexUnfilteredWorkspaceGroupIDs() -> [CodexWorkspaceGroupID] {
+ var seen: Set = []
+ return codexSidebarSourceSections
+ .compactMap(\.sidebarWorkspaceGroupID)
+ .filter { seen.insert($0).inserted }
+ }
+
+ private func codexWorkspaceGroupBeforeID(
+ movingID: CodexWorkspaceGroupID,
+ visibleOrder: [CodexWorkspaceGroupID],
+ currentOrder: [CodexWorkspaceGroupID]
+ ) -> CodexWorkspaceGroupID? {
+ guard let lastVisibleID = visibleOrder.last,
+ let lastVisibleIndex = currentOrder.firstIndex(of: lastVisibleID)
+ else {
+ return nil
+ }
+ let nextIndex = lastVisibleIndex + 1
+ guard nextIndex < currentOrder.count,
+ currentOrder[nextIndex] != movingID
+ else {
+ return nil
+ }
+ return currentOrder[nextIndex]
+ }
+
+ private func codexWorkspaceGroupRootSegment(containing rootIndex: Int) -> Range {
+ var lowerBound = rootIndex
+ while lowerBound > 0,
+ codexSidebarOutlineTree.roots[lowerBound - 1].workspaceGroupID != nil
+ {
+ lowerBound -= 1
+ }
+
+ var upperBound = rootIndex + 1
+ while upperBound < codexSidebarOutlineTree.roots.count,
+ codexSidebarOutlineTree.roots[upperBound].workspaceGroupID != nil
+ {
+ upperBound += 1
+ }
+ return lowerBound.. SidebarResolvedDrop? {
+ guard uiState.sidebarReviewChatFilter.allowsReviewChatReordering,
+ let destination = resolvedCodexChatDropDestination(
+ sourceContainer: container,
+ draggingLocation: draggingLocation,
+ proposedItem: proposedItem,
+ proposedChildIndex: index
+ ),
+ destination.container == container
+ else {
+ return nil
+ }
+
+ let currentOrder = codexUnfilteredChatIDs(in: container)
+ let visibleOrder = codexVisibleChatIDs(in: container)
+ guard currentOrder.contains(id),
+ let visibleSourceIndex = visibleOrder.firstIndex(of: id)
+ else {
+ return nil
+ }
+
+ let visibleInsertionIndex = max(0, min(destination.childIndex, visibleOrder.count))
+ let beforeID = codexChatBeforeID(
+ movingID: id,
+ visibleInsertionIndex: visibleInsertionIndex,
+ visibleOrder: visibleOrder,
+ currentOrder: currentOrder
+ )
+ let displayDestinationIndex =
+ visibleInsertionIndex > visibleSourceIndex
+ ? visibleInsertionIndex - 1
+ : visibleInsertionIndex
+ guard displayDestinationIndex != visibleSourceIndex || beforeID == nil else {
+ return nil
+ }
+
+ return SidebarResolvedDrop(
+ operation: .reorderCodexChat(
+ id: id,
+ container: container,
+ currentOrder: currentOrder,
+ beforeID: beforeID
+ ),
+ dropItem: destination.dropItem,
+ dropChildIndex: destination.dropChildIndex
+ )
+ }
+
+ private func resolvedCodexChatDropDestination(
+ sourceContainer: ReviewMonitorCodexSidebarRowID,
+ draggingLocation: NSPoint?,
+ proposedItem: Any?,
+ proposedChildIndex index: Int
+ ) -> SidebarCodexChatDropDestination? {
+ if let containerNode = codexSidebarNode(from: proposedItem),
+ isCodexChatContainer(containerNode),
+ index != NSOutlineViewDropOnItemIndex
+ {
+ let childIndex = max(0, min(index, codexVisibleChatIDs(in: containerNode.rowID).count))
+ return SidebarCodexChatDropDestination(
+ container: containerNode.rowID,
+ childIndex: childIndex,
+ dropItem: containerNode,
+ dropChildIndex: childIndex
+ )
+ }
+
+ guard let targetNode = codexSidebarNode(from: proposedItem),
+ case .chat(let targetChatID) = targetNode.item,
+ index == NSOutlineViewDropOnItemIndex,
+ let parentNode = outlineView.parent(forItem: targetNode) as? ReviewMonitorCodexSidebarOutlineNode,
+ parentNode.rowID == sourceContainer,
+ let targetIndex = codexVisibleChatIDs(in: parentNode.rowID).firstIndex(of: targetChatID),
+ let draggingLocation,
+ let targetRow = row(forCodexSidebarSelectionID: .chat(targetChatID))
+ else {
+ return nil
+ }
+
+ let targetRowRect = outlineView.rect(ofRow: targetRow)
+ let childIndex = targetIndex + (draggingLocation.y < targetRowRect.midY ? 0 : 1)
+ return SidebarCodexChatDropDestination(
+ container: parentNode.rowID,
+ childIndex: childIndex,
+ dropItem: parentNode,
+ dropChildIndex: childIndex
+ )
+ }
+
+ private func codexRootInsertionIndex(
+ draggingLocation: NSPoint?,
+ proposedItem: Any?,
+ proposedChildIndex index: Int
+ ) -> Int? {
+ if proposedItem == nil,
+ index != NSOutlineViewDropOnItemIndex
+ {
+ return max(0, min(index, codexSidebarOutlineTree.roots.count))
+ }
+
+ if let draggingLocation,
+ outlineView.numberOfRows > 0
+ {
+ let firstRowRect = outlineView.rect(ofRow: 0)
+ if draggingLocation.y < firstRowRect.minY {
+ return 0
+ }
+ let lastRowRect = outlineView.rect(ofRow: outlineView.numberOfRows - 1)
+ if draggingLocation.y > lastRowRect.maxY {
+ return codexSidebarOutlineTree.roots.count
+ }
+ }
+
+ guard let targetRoot = codexRootNode(for: proposedItem),
+ let targetIndex = codexRootIndex(for: targetRoot)
+ else {
+ return nil
+ }
+
+ guard let draggingLocation,
+ let targetRect = codexRootRect(for: targetRoot)
+ else {
+ return targetIndex
+ }
+ return draggingLocation.y < targetRect.midY ? targetIndex : targetIndex + 1
+ }
+
+ private func codexRootNode(for item: Any?) -> ReviewMonitorCodexSidebarOutlineNode? {
+ guard var node = codexSidebarNode(from: item) else {
+ return nil
+ }
+ while let parent = outlineView.parent(forItem: node) as? ReviewMonitorCodexSidebarOutlineNode {
+ node = parent
+ }
+ return node
+ }
+
+ private func codexRootIndex(for node: ReviewMonitorCodexSidebarOutlineNode) -> Int? {
+ codexSidebarOutlineTree.roots.firstIndex { $0 === node }
+ }
+
+ private func codexRootRect(for root: ReviewMonitorCodexSidebarOutlineNode) -> NSRect? {
+ let rootRow = outlineView.row(forItem: root)
+ guard rootRow != -1 else {
+ return nil
+ }
+ var rect = outlineView.rect(ofRow: rootRow)
+ for descendant in codexVisibleDescendants(of: root) {
+ let row = outlineView.row(forItem: descendant)
+ if row != -1 {
+ rect = rect.union(outlineView.rect(ofRow: row))
+ }
+ }
+ return rect
+ }
+
+ private func codexVisibleDescendants(
+ of node: ReviewMonitorCodexSidebarOutlineNode
+ ) -> [ReviewMonitorCodexSidebarOutlineNode] {
+ guard outlineView.isItemExpanded(node) else {
+ return []
+ }
+ return node.children.flatMap { child in
+ [child] + codexVisibleDescendants(of: child)
+ }
+ }
+
+ private func isCodexChatContainer(_ node: ReviewMonitorCodexSidebarOutlineNode) -> Bool {
+ switch node.item {
+ case .section, .workspaceGroup:
+ return node.children.contains { $0.item.isChat }
+ case .chat:
+ return false
+ }
+ }
+
+ private func codexVisibleChatIDs(in container: ReviewMonitorCodexSidebarRowID) -> [CodexThreadID] {
+ guard let node = codexSidebarOutlineTree.node(rowID: container) else {
+ return []
+ }
+ return node.children.compactMap(\.item.chatID)
+ }
+
+ private func codexUnfilteredChatIDs(in container: ReviewMonitorCodexSidebarRowID) -> [CodexThreadID] {
+ for section in codexSidebarSourceSections {
+ if section.rowID == container {
+ return section.items.map(\.id)
+ }
+ }
+ return []
+ }
+
+ private func codexChatBeforeID(
+ movingID: CodexThreadID,
+ visibleInsertionIndex: Int,
+ visibleOrder: [CodexThreadID],
+ currentOrder: [CodexThreadID]
+ ) -> CodexThreadID? {
+ if visibleInsertionIndex < visibleOrder.count {
+ let targetID = visibleOrder[visibleInsertionIndex]
+ return targetID == movingID ? movingID : targetID
+ }
+ guard let lastVisibleID = visibleOrder.last,
+ let lastVisibleIndex = currentOrder.firstIndex(of: lastVisibleID)
+ else {
+ return nil
+ }
+ let nextIndex = lastVisibleIndex + 1
+ guard nextIndex < currentOrder.count,
+ currentOrder[nextIndex] != movingID
+ else {
+ return nil
+ }
+ return currentOrder[nextIndex]
+ }
+
+ @discardableResult
+ private func applyResolvedDrop(_ resolvedDrop: SidebarResolvedDrop) -> Bool {
+ switch resolvedDrop.operation {
+ case .none:
+ return false
+ case .reorderCodexWorkspaceGroup(let id, let currentOrder, let beforeID):
+ guard
+ codexSidebarPresentationOrder.reorderWorkspaceGroup(
+ id: id,
+ currentOrder: currentOrder,
+ before: beforeID
+ )
+ else {
+ return false
+ }
+ applyFilteredCodexSidebarSections()
+ return true
+ case .reorderCodexChat(let id, let container, let currentOrder, let beforeID):
+ guard
+ codexSidebarPresentationOrder.reorderChat(
+ id: id,
+ in: container,
+ currentOrder: currentOrder,
+ before: beforeID
+ )
+ else {
+ return false
+ }
+ applyFilteredCodexSidebarSections()
+ return true
+ }
+ }
+
+ private func makeCodexSidebarCellView(for node: ReviewMonitorCodexSidebarOutlineNode) -> NSView? {
+ switch node.item {
+ case .chat:
+ let view =
+ (outlineView.makeView(withIdentifier: Identifier.reviewChatCell, owner: self)
+ as? ReviewMonitorReviewChatCellView)
+ ?? ReviewMonitorReviewChatCellView()
+ view.identifier = Identifier.reviewChatCell
+ guard configureCodexSidebarCell(view, for: node) else {
+ return nil
+ }
+ return view
+ case .section, .workspaceGroup:
+ let view =
+ (outlineView.makeView(withIdentifier: Identifier.workspaceGroupCell, owner: self)
+ as? ReviewMonitorWorkspaceGroupCellView)
+ ?? ReviewMonitorWorkspaceGroupCellView()
+ view.identifier = Identifier.workspaceGroupCell
+ guard configureCodexSidebarCell(view, for: node) else {
+ return nil
+ }
+ return view
+ }
+ }
+
+ @discardableResult
+ private func configureCodexSidebarCell(
+ _ cellView: NSView,
+ for node: ReviewMonitorCodexSidebarOutlineNode
+ ) -> Bool {
+ switch node.item {
+ case .chat(let id):
+ guard let cellView = cellView as? ReviewMonitorReviewChatCellView,
+ let chat = displayedCodexChat(id: id)
+ else {
+ return false
+ }
+ cellView.configure(with: chat)
+ return true
+ case .section:
+ guard let cellView = cellView as? ReviewMonitorWorkspaceGroupCellView else {
+ return false
+ }
+ cellView.configureFallbackWorkspaceGroup(title: codexSidebarTitle(for: node) ?? node.rowID.rawValue)
+ return true
+ case .workspaceGroup(let id):
+ guard let cellView = cellView as? ReviewMonitorWorkspaceGroupCellView else {
+ return false
+ }
+ if let workspaceGroup = displayedCodexSidebarSection(id: id)?.workspaceGroup
+ ?? codexWorkspaceGroupSection(id: id)?.workspaceGroup
+ {
+ cellView.configure(workspaceGroup: workspaceGroup)
+ } else {
+ cellView.configureFallbackWorkspaceGroup(title: codexSidebarTitle(for: node) ?? id.rawValue)
+ }
+ return true
+ }
+ }
+
+ func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
+ guard let item else {
+ return codexSidebarOutlineTree.roots.count
+ }
+ if let node = codexSidebarNode(from: item) {
+ return node.children.count
+ }
+ return 0
+ }
+
+ func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any {
+ guard let item else {
+ return codexSidebarOutlineTree.roots[index]
+ }
+ if let node = codexSidebarNode(from: item) {
+ return node.children[index]
+ }
+ fatalError("Unsupported Codex sidebar item.")
+ }
+
+ func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool {
+ if let node = codexSidebarNode(from: item) {
+ return node.isExpandable
+ }
+ return false
+ }
+
+ func outlineView(_ outlineView: NSOutlineView, isGroupItem item: Any) -> Bool {
+ false
+ }
+
+ func outlineView(_ outlineView: NSOutlineView, heightOfRowByItem item: Any) -> CGFloat {
+ if let node = codexSidebarNode(from: item) {
+ if node.item.isChat {
+ return rowHeights.reviewChat
+ }
+ return rowHeights.workspaceGroup
+ }
+ return rowHeights.reviewChat
+ }
+
+ func outlineView(_ outlineView: NSOutlineView, shouldCollapseItem item: Any) -> Bool {
+ true
+ }
+
+ func outlineView(_ outlineView: NSOutlineView, shouldExpandItem item: Any) -> Bool {
+ true
+ }
+
+ func outlineView(_ outlineView: NSOutlineView, shouldSelectItem item: Any) -> Bool {
+ shouldAllowSelection(of: item)
+ }
+
+ func outlineView(
+ _ outlineView: NSOutlineView,
+ pasteboardWriterForItem item: Any
+ ) -> (any NSPasteboardWriting)? {
+ guard let payload = dragPayload(for: item) else {
+ return nil
+ }
+ return makePasteboardItem(for: payload)
+ }
+
+ func outlineView(
+ _ outlineView: NSOutlineView,
+ validateDrop info: any NSDraggingInfo,
+ proposedItem item: Any?,
+ proposedChildIndex index: Int
+ ) -> NSDragOperation {
+ let draggingLocation = outlineView.convert(info.draggingLocation, from: nil)
+ guard let payload = dragPayload(from: info),
+ let resolvedDrop = resolvedDrop(
+ for: payload,
+ draggingLocation: draggingLocation,
+ proposedItem: item,
+ proposedChildIndex: index
+ )
+ else {
+ clearDropTarget()
+ return []
+ }
+
+ outlineView.setDropItem(resolvedDrop.dropItem, dropChildIndex: resolvedDrop.dropChildIndex)
+ return .move
+ }
+
+ func outlineView(
+ _ outlineView: NSOutlineView,
+ acceptDrop info: any NSDraggingInfo,
+ item: Any?,
+ childIndex index: Int
+ ) -> Bool {
+ defer {
+ clearDropTarget()
+ }
+ let draggingLocation = outlineView.convert(info.draggingLocation, from: nil)
+ guard let payload = dragPayload(from: info),
+ let resolvedDrop = resolvedDrop(
+ for: payload,
+ draggingLocation: draggingLocation,
+ proposedItem: item,
+ proposedChildIndex: index
+ )
+ else {
+ return false
+ }
+ info.animatesToDestination = false
+ return applyResolvedDrop(resolvedDrop)
+ }
+
+ func outlineViewSelectionDidChange(_ notification: Notification) {
+ updateSelectionFromOutlineView()
+ }
+
+ func outlineViewItemDidExpand(_ notification: Notification) {
+ _ = notification
+ }
+
+ func outlineViewItemDidCollapse(_ notification: Notification) {
+ _ = notification
+ }
+
+ func outlineView(_ outlineView: NSOutlineView, rowViewForItem item: Any) -> NSTableRowView? {
+ if let node = codexSidebarNode(from: item) {
+ if node.item.isChat {
+ return ReviewMonitorReviewChatTableRowView()
+ }
+ return ReviewMonitorWorkspaceGroupRowView()
+ }
+ return nil
+ }
+
+ func outlineView(
+ _ outlineView: NSOutlineView,
+ viewFor tableColumn: NSTableColumn?,
+ item: Any
+ ) -> NSView? {
+ guard let node = codexSidebarNode(from: item) else {
+ return nil
+ }
+ return makeCodexSidebarCellView(for: node)
+ }
+
+}
+
+#if DEBUG
+ @MainActor
+ extension ReviewMonitorSidebarViewController {
+ var sidebarKindObservationForTesting: PortableObservationTracking.Token? {
+ sidebarKindObservation
+ }
+
+ var sidebarFilterObservationForTesting: PortableObservationTracking.Token? {
+ sidebarFilterObservation
+ }
+
+ var sidebarKindForTesting: SidebarKind {
+ sidebarKind
+ }
+
+ var accountsViewControllerForTesting: ReviewMonitorAccountsViewController {
+ accountsViewController.loadViewIfNeeded()
+ return accountsViewController
+ }
+
+ var displayedSectionTitlesForTesting: [String] {
+ codexSidebarRootTitlesForTesting
+ }
+
+ var selectedReviewChatIDForTesting: CodexThreadID? {
+ guard case .chat(let id) = uiState.selection else {
+ return nil
+ }
+ return id
+ }
+
+ var nativeSelectedReviewChatIDForTesting: CodexThreadID? {
+ guard outlineView.selectedRow != -1,
+ let node = codexSidebarNode(from: outlineView.item(atRow: outlineView.selectedRow)),
+ case .chat(let id) = node.item
+ else {
+ return nil
+ }
+ return id
+ }
+
+ var selectedWorkspaceGroupIDForTesting: CodexWorkspaceGroupID? {
+ guard case .workspaceGroup(let id) = uiState.selection else {
+ return nil
+ }
+ return id
+ }
+
+ func workspaceGroupIDForTesting(cwd: String) -> CodexWorkspaceGroupID? {
+ workspaceGroupID(forWorkspaceCWD: cwd)
+ }
+
+ var codexSidebarSectionsForTesting: [CodexFetchSection] {
+ codexSidebarResultsController?.sections ?? []
+ }
+
+ var codexSidebarRootTitlesForTesting: [String] {
+ codexSidebarOutlineTree.roots.map { codexSidebarTitle(for: $0) ?? $0.rowID.rawValue }
+ }
+
+ var displayedCodexSidebarTitlesForTesting: [String] {
+ (0.. String? {
+ codexSidebarOutlineTree.node(rowID: rowID).flatMap { codexSidebarTitle(for: $0) }
+ }
+
+ func displayedCodexChatIDsForTesting(container: ReviewMonitorCodexSidebarRowID) -> [CodexThreadID] {
+ codexVisibleChatIDs(in: container)
+ }
+
+ func codexSidebarCanStartDragForTesting(rowID: ReviewMonitorCodexSidebarRowID) -> Bool {
+ guard let node = codexSidebarOutlineTree.node(rowID: rowID) else {
+ return false
+ }
+ return dragPayload(for: node) != nil
+ }
+
+ @discardableResult
+ func performCodexWorkspaceGroupDropForTesting(id: CodexWorkspaceGroupID, toIndex index: Int) -> Bool {
+ guard
+ let resolvedDrop = resolvedDrop(
+ for: .codexWorkspaceGroup(id: id.rawValue),
+ proposedItem: nil,
+ proposedChildIndex: index
+ )
+ else {
+ return false
+ }
+ return applyResolvedDrop(resolvedDrop)
+ }
+
+ @discardableResult
+ func performCodexChatDropForTesting(
+ id: CodexThreadID,
+ container: ReviewMonitorCodexSidebarRowID,
+ childIndex: Int
+ ) -> Bool {
+ guard let containerNode = codexSidebarOutlineTree.node(rowID: container),
+ let resolvedDrop = resolvedDrop(
+ for: .codexChat(id: id.rawValue, containerRowID: container.rawValue),
+ proposedItem: containerNode,
+ proposedChildIndex: childIndex
+ )
+ else {
+ return false
+ }
+ return applyResolvedDrop(resolvedDrop)
+ }
+
+ func selectCodexSidebarRowForTesting(rowID: ReviewMonitorCodexSidebarRowID) {
+ guard let node = codexSidebarOutlineTree.node(rowID: rowID) else {
+ return
+ }
+ let row = outlineView.row(forItem: node)
+ guard row != -1 else {
+ return
+ }
+ outlineView.selectUserInitiatedRowForTesting(row)
+ }
+
+ func selectCodexSidebarRowProgrammaticallyForTesting(rowID: ReviewMonitorCodexSidebarRowID) {
+ guard let node = codexSidebarOutlineTree.node(rowID: rowID) else {
+ return
+ }
+ let row = outlineView.row(forItem: node)
+ guard row != -1 else {
+ return
+ }
+ outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
+ }
+
+ func refreshCodexSidebarForTesting() async throws {
+ try await codexSidebarResultsController?.refresh()
+ applyCodexSidebarSourceSections(codexSidebarResultsController?.sections ?? [])
+ }
+
+ var sidebarFullReloadCountForTesting: Int {
+ fullReloadCountForTesting
+ }
+
+ var isShowingEmptyStateForTesting: Bool {
+ emptyStateViewController.view.isHidden == false
+ }
+
+ func selectReviewChatForTesting(chat: CodexChat) {
+ guard let row = row(forCodexSidebarSelectionID: .chat(chat.id)) else {
+ uiState.selection = .chat(chat.id)
+ return
+ }
+ outlineView.selectUserInitiatedRowForTesting(row)
+ }
+
+ func selectReviewChatForTesting(id chatID: CodexThreadID) {
+ if let chat = currentChatSelection(id: chatID) {
+ selectReviewChatForTesting(chat: chat)
+ return
+ }
+ guard let row = row(forCodexSidebarSelectionID: .chat(chatID)) else {
+ uiState.selection = .chat(chatID)
+ return
+ }
+ outlineView.selectUserInitiatedRowForTesting(row)
+ }
+
+ func presentContextMenuForTesting(
+ chatID: CodexThreadID,
+ presenter: @escaping (NSMenu) -> Void
+ ) {
+ view.layoutSubtreeIfNeeded()
+ guard let row = row(for: chatID) else {
+ return
+ }
+ let rowRect = outlineView.rect(ofRow: row)
+ outlineView.presentContextMenuForTesting(
+ at: NSPoint(x: rowRect.midX, y: rowRect.midY),
+ presenter: presenter
+ )
+ }
+
+ func setChatArchiveConfirmationForTesting(
+ _ action: @escaping ReviewMonitorChatArchiveConfirmation.Action
+ ) {
+ chatArchiveConfirmation = ReviewMonitorChatArchiveConfirmation(action: action)
+ }
+
+ func selectWorkspaceForTesting(cwd: String) {
+ guard let row = row(forWorkspaceCWD: cwd) else {
+ uiState.selection = fallbackWorkspaceGroupSelection(cwd: cwd)
+ return
+ }
+ outlineView.selectUserInitiatedRowForTesting(row)
+ }
+
+ func clearSelectionForTesting() {
+ uiState.selection = nil
+ outlineView.deselectAll(nil)
+ }
+
+ var allWorkspaceRowsExpandedForTesting: Bool {
+ codexSidebarOutlineTree.roots.allSatisfy { outlineView.isItemExpanded($0) }
+ }
+
+ func workspaceIsSelectableForTesting(cwd: String) -> Bool {
+ row(forWorkspaceCWD: cwd).map { shouldAllowSelection(of: outlineView.item(atRow: $0)) } ?? false
+ }
+
+ var floatsGroupRowsEnabledForTesting: Bool {
+ outlineView.floatsGroupRows
+ }
+
+ var draggingDestinationFeedbackStyleForTesting: NSTableView.DraggingDestinationFeedbackStyle {
+ outlineView.draggingDestinationFeedbackStyle
+ }
+
+ var sidebarUsesAutomaticRowHeightsForTesting: Bool {
+ outlineView.usesAutomaticRowHeights
+ }
+
+ var expectedWorkspaceRowRectHeightForTesting: CGFloat {
+ rowHeights.workspaceGroup + outlineView.intercellSpacing.height
+ }
+
+ var expectedReviewChatRowRectHeightForTesting: CGFloat {
+ rowHeights.reviewChat + outlineView.intercellSpacing.height
+ }
+
+ func workspaceRowHeightForTesting(cwd: String) -> CGFloat? {
+ guard let row = row(forWorkspaceCWD: cwd) else {
+ return nil
+ }
+ view.layoutSubtreeIfNeeded()
+ return outlineView.rect(ofRow: row).height
+ }
+
+ func reviewChatRowHeightForTesting(_ chatID: CodexThreadID) -> CGFloat? {
+ guard let row = row(for: chatID) else {
+ return nil
+ }
+ view.layoutSubtreeIfNeeded()
+ return outlineView.rect(ofRow: row).height
+ }
+
+ func workspaceCellMinXForTesting(cwd: String) -> CGFloat? {
+ guard let row = row(forWorkspaceCWD: cwd) else {
+ return nil
+ }
+ view.layoutSubtreeIfNeeded()
+ guard
+ let cellView = outlineView.view(
+ atColumn: 0,
+ row: row,
+ makeIfNecessary: true
+ ) as? ReviewMonitorWorkspaceGroupCellView
+ else {
+ return nil
+ }
+ outlineView.layoutSubtreeIfNeeded()
+ return cellView.contentMinXForTesting(relativeTo: outlineView)
+ }
+
+ func workspaceDisclosureMaxXForTesting(cwd: String) -> CGFloat? {
+ guard let row = row(forWorkspaceCWD: cwd) else {
+ return nil
+ }
+ view.layoutSubtreeIfNeeded()
+ outlineView.layoutSubtreeIfNeeded()
+ let disclosureFrame = outlineView.frameOfOutlineCell(atRow: row)
+ return disclosureFrame.width > 0 ? disclosureFrame.maxX : nil
+ }
+
+ func reviewChatCellMinXForTesting(_ chatID: CodexThreadID) -> CGFloat? {
+ guard let row = row(for: chatID) else {
+ return nil
+ }
+ view.layoutSubtreeIfNeeded()
+ guard
+ let cellView = outlineView.view(
+ atColumn: 0,
+ row: row,
+ makeIfNecessary: true
+ ) as? ReviewMonitorReviewChatCellView
+ else {
+ return nil
+ }
+ outlineView.layoutSubtreeIfNeeded()
+ return cellView.contentMinXForTesting(relativeTo: outlineView)
+ }
+
+ func clickBlankAreaForTesting() {
+ view.layoutSubtreeIfNeeded()
+ guard outlineView.numberOfRows > 0 else {
+ return
+ }
+ let point = blankPointForTesting()
+ guard outlineView.suppressesSelectionClearingForTesting(at: point) else {
+ return
+ }
+ outlineView.mouseDown(with: mouseEventForTesting(at: point))
+ }
+
+ func clickWorkspaceHeaderForTesting(cwd: String) {
+ view.layoutSubtreeIfNeeded()
+ let workspaceGroupID = workspaceGroupID(forWorkspaceCWD: cwd) ?? CodexWorkspaceGroupID(rawValue: cwd)
+ guard
+ let row = row(forCodexSidebarSelectionID: .workspaceGroup(workspaceGroupID))
+ ?? row(forWorkspaceCWD: cwd)
+ else {
+ uiState.selection = fallbackWorkspaceGroupSelection(cwd: cwd)
+ return
+ }
+ outlineView.selectUserInitiatedRowForTesting(row)
+ updateSelectionFromOutlineView(acceptingProgrammaticSelection: true)
+ }
+
+ func focusSidebarForTesting() {
+ _ = view.window?.makeFirstResponder(outlineView)
+ }
+
+ var sidebarHasFirstResponderForTesting: Bool {
+ view.window?.firstResponder === outlineView
+ }
+
+ var isPresentingContextMenuForTesting: Bool {
+ outlineView.isPresentingContextMenuForTesting
+ }
+
+ var acceptsFirstResponderForTesting: Bool {
+ outlineView.acceptsFirstResponderForTesting
+ }
+
+ var hasTemporaryContextMenuForTesting: Bool {
+ outlineView.menu != nil
+ }
+
+ func workspaceIsExpandedForTesting(cwd: String) -> Bool {
+ workspaceOutlineIsExpandedForTesting(cwd: cwd)
+ }
+
+ func workspaceOutlineIsExpandedForTesting(cwd: String) -> Bool {
+ guard let row = row(forWorkspaceCWD: cwd),
+ let item = outlineView.item(atRow: row)
+ else {
+ return false
+ }
+ return outlineView.isItemExpanded(item)
+ }
+
+ func toggleWorkspaceDisclosureForTesting(cwd: String) {
+ guard let row = row(forWorkspaceCWD: cwd),
+ let item = outlineView.item(atRow: row)
+ else {
+ preconditionFailure("Workspace row is not visible.")
+ }
+ if outlineView.isItemExpanded(item) {
+ outlineView.collapseItem(item)
+ } else {
+ outlineView.expandItem(item)
+ }
+ }
+
+ func collapseWorkspaceInOutlineForTesting(cwd: String) {
+ guard let row = row(forWorkspaceCWD: cwd),
+ let item = outlineView.item(atRow: row)
+ else {
+ preconditionFailure("Workspace row is not visible.")
+ }
+ outlineView.collapseItem(item)
+ }
+
+ func expandWorkspaceInOutlineForTesting(cwd: String) {
+ guard let row = row(forWorkspaceCWD: cwd),
+ let item = outlineView.item(atRow: row)
+ else {
+ preconditionFailure("Workspace row is not visible.")
+ }
+ outlineView.expandItem(item)
+ }
+
+ func workspaceRowIsFloatingForTesting(cwd: String) -> Bool {
+ guard let row = row(forWorkspaceCWD: cwd),
+ let rowView = outlineView.rowView(atRow: row, makeIfNecessary: true)
+ else {
+ return false
+ }
+ return rowView.isFloating
+ }
+
+ private func fallbackWorkspaceGroupSelection(cwd: String) -> ReviewMonitorSelection {
+ .workspaceGroup(CodexWorkspaceGroupID(rawValue: cwd))
+ }
+
+ func scrollSidebarToOffsetForTesting(_ yOffset: CGFloat) {
+ let clampedOffset = max(0, yOffset)
+ scrollView.contentView.scroll(to: NSPoint(x: 0, y: clampedOffset))
+ scrollView.reflectScrolledClipView(scrollView.contentView)
+ view.layoutSubtreeIfNeeded()
+ }
+
+ var sidebarVisibleHeightForTesting: CGFloat {
+ scrollView.documentVisibleRect.height
+ }
+
+ var safeAreaFrameForTesting: NSRect {
+ view.safeAreaRect
+ }
+
+ var scrollViewFrameForTesting: NSRect {
+ scrollView.frame
+ }
+
+ var sidebarDocumentHeightForTesting: CGFloat {
+ outlineView.frame.height
+ }
+
+ var sidebarOutlineContentHeightForTesting: CGFloat {
+ guard outlineView.numberOfRows > 0 else {
+ return 0
+ }
+ return outlineView.rect(ofRow: outlineView.numberOfRows - 1).maxY
+ }
+
+ var sidebarMaximumVerticalScrollOffsetForTesting: CGFloat {
+ max(0, sidebarDocumentHeightForTesting - sidebarVisibleHeightForTesting)
+ }
+
+ var sidebarVisibleRectForTesting: NSRect {
+ scrollView.documentVisibleRect
+ }
+
+ var sidebarFirstRowRectForTesting: NSRect {
+ guard outlineView.numberOfRows > 0 else {
+ return .zero
+ }
+ return outlineView.rect(ofRow: 0)
+ }
+
+ var sidebarLastRowRectForTesting: NSRect {
+ guard outlineView.numberOfRows > 0 else {
+ return .zero
+ }
+ return outlineView.rect(ofRow: outlineView.numberOfRows - 1)
+ }
+
+ private func blankPointForTesting() -> NSPoint {
+ let blankY: CGFloat
+ if outlineView.numberOfRows > 0 {
+ blankY = outlineView.rect(ofRow: outlineView.numberOfRows - 1).maxY + 10
+ } else {
+ blankY = outlineView.bounds.midY
+ }
+ let x = min(outlineView.bounds.maxX - 1, max(1, outlineView.bounds.midX))
+ let y = min(outlineView.bounds.maxY - 1, max(1, blankY))
+ return NSPoint(x: x, y: y)
+ }
+
+ private func mouseEventForTesting(at point: NSPoint) -> NSEvent {
+ guard let window = view.window else {
+ fatalError("Sidebar view controller must be attached to a window for click testing.")
+ }
+ let locationInWindow = outlineView.convert(point, to: nil)
+ guard
+ let event = NSEvent.mouseEvent(
+ with: .leftMouseDown,
+ location: locationInWindow,
+ modifierFlags: [],
+ timestamp: ProcessInfo.processInfo.systemUptime,
+ windowNumber: window.windowNumber,
+ context: nil,
+ eventNumber: 0,
+ clickCount: 1,
+ pressure: 1
+ )
+ else {
+ fatalError("Failed to create a synthetic mouse event.")
+ }
+ return event
+ }
+ }
+#endif
+
+@MainActor
+private final class ReviewMonitorSidebarOutlineView: NSOutlineView {
+ var contextMenuProvider: ((NSPoint) -> NSMenu?)?
+ var draggingExitedHandler: (() -> Void)?
+ private var isPresentingContextMenu = false
+ private var userSelectionInteractionDepth = 0
+ private var hasPendingUserSelectionChangeIntent = false
+ private weak var contextMenuFirstResponder: NSResponder?
+ private var previousContextMenu: NSMenu?
+
+ override var acceptsFirstResponder: Bool {
+ isPresentingContextMenu ? false : super.acceptsFirstResponder
+ }
+
+ @objc(_indentationForRow:withLevel:isSourceListGroupRow:)
+ dynamic func indentationForRow(
+ _ row: Int,
+ withLevel level: Int,
+ isSourceListGroupRow: Bool
+ ) -> CGFloat {
+ // Workspace group rows keep AppKit's native disclosure gutter. Review
+ // chat rows use the SwiftUI Label icon slot as the visual indent.
+ level <= 0 ? max(indentationPerLevel, 0) : 0
+ }
+
+ override func mouseDown(with event: NSEvent) {
+ let point = convert(event.locationInWindow, from: nil)
+ handlePrimaryInteraction(at: point) {
+ performUserSelectionInteraction {
+ super.mouseDown(with: event)
+ }
+ }
+ }
+
+ override func keyDown(with event: NSEvent) {
+ performUserSelectionInteraction {
+ super.keyDown(with: event)
+ }
+ }
+
+ override func rightMouseDown(with event: NSEvent) {
+ let point = convert(event.locationInWindow, from: nil)
+ guard let contextMenu = contextMenuProvider?(point) else {
+ super.rightMouseDown(with: event)
+ return
+ }
+
+ beginContextMenuPresentation(with: contextMenu)
+ super.rightMouseDown(with: event)
+
+ if isPresentingContextMenu {
+ endContextMenuPresentation()
+ }
+ }
+
+ override func draggingExited(_ sender: (any NSDraggingInfo)?) {
+ draggingExitedHandler?()
+ super.draggingExited(sender)
+ }
+
+ override func didCloseMenu(_ menu: NSMenu, with event: NSEvent?) {
+ super.didCloseMenu(menu, with: event)
+ guard isPresentingContextMenu else {
+ return
+ }
+ endContextMenuPresentation()
+ }
+
+ private func shouldSuppressSelectionClearing(at point: NSPoint) -> Bool {
+ guard selectedRow != -1 else {
+ return false
+ }
+ return row(at: point) == -1
+ }
+
+ private func handlePrimaryInteraction(
+ at point: NSPoint,
+ action: () -> Void
+ ) {
+ guard shouldSuppressSelectionClearing(at: point) == false else {
+ return
+ }
+
+ action()
+ }
+
+ func consumeUserSelectionChangeIntent() -> Bool {
+ guard userSelectionInteractionDepth > 0 || hasPendingUserSelectionChangeIntent else {
+ return false
+ }
+ if userSelectionInteractionDepth == 0 {
+ hasPendingUserSelectionChangeIntent = false
+ }
+ return true
+ }
+
+ private func performUserSelectionInteraction(_ action: () -> Void) {
+ userSelectionInteractionDepth += 1
+ hasPendingUserSelectionChangeIntent = true
+ defer {
+ userSelectionInteractionDepth -= 1
+ schedulePendingUserSelectionIntentClear()
+ }
+ action()
+ }
+
+ private func schedulePendingUserSelectionIntentClear() {
+ Task { @MainActor [weak self] in
+ guard let self,
+ self.userSelectionInteractionDepth == 0
+ else {
+ return
+ }
+ self.hasPendingUserSelectionChangeIntent = false
+ }
+ }
+
+ private func isSidebarFirstResponder(_ responder: NSResponder) -> Bool {
+ if responder === self {
+ return true
+ }
+ guard let view = responder as? NSView else {
+ return false
+ }
+ return view === self || view.isDescendant(of: self)
+ }
+
+ private func restoreFirstResponder(_ responder: NSResponder?) {
+ guard let window else {
+ return
+ }
+ if let view = responder as? NSView, view.window === window {
+ _ = window.makeFirstResponder(view)
+ return
+ }
+ _ = window.makeFirstResponder(self)
+ }
+
+ private func beginContextMenuPresentation(with contextMenu: NSMenu) {
+ previousContextMenu = menu
+ menu = contextMenu
+ isPresentingContextMenu = true
+
+ guard let window else {
+ contextMenuFirstResponder = nil
+ return
+ }
+
+ let previousFirstResponder = window.firstResponder
+ guard previousFirstResponder.map(isSidebarFirstResponder(_:)) ?? false else {
+ contextMenuFirstResponder = nil
+ return
+ }
+
+ contextMenuFirstResponder = previousFirstResponder
+ _ = window.makeFirstResponder(nil)
+ }
+
+ private func endContextMenuPresentation() {
+ let previousFirstResponder = contextMenuFirstResponder
+ let previousContextMenu = previousContextMenu
+
+ contextMenuFirstResponder = nil
+ self.previousContextMenu = nil
+ isPresentingContextMenu = false
+ menu = previousContextMenu
+
+ guard let previousFirstResponder else {
+ return
+ }
+ restoreFirstResponder(previousFirstResponder)
+ }
+
+ #if DEBUG
+ func suppressesSelectionClearingForTesting(at point: NSPoint) -> Bool {
+ shouldSuppressSelectionClearing(at: point)
+ }
+
+ func presentContextMenuForTesting(
+ at point: NSPoint,
+ presenter: @escaping (NSMenu) -> Void
+ ) {
+ guard window != nil else {
+ fatalError("Sidebar outline view must be attached to a window for context menu testing.")
+ }
+ guard let contextMenu = contextMenuProvider?(point) else {
+ return
+ }
+ beginContextMenuPresentation(with: contextMenu)
+ presenter(contextMenu)
+ if isPresentingContextMenu {
+ endContextMenuPresentation()
+ }
+ }
+
+ var isPresentingContextMenuForTesting: Bool {
+ isPresentingContextMenu
+ }
+
+ var acceptsFirstResponderForTesting: Bool {
+ acceptsFirstResponder
+ }
+
+ func selectUserInitiatedRowForTesting(_ row: Int) {
+ performUserSelectionInteraction {
+ selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
+ }
+ }
+
+ #endif
+}
+
+@MainActor
+private final class ReviewMonitorWorkspaceGroupRowView: NSTableRowView {
+ override var isEmphasized: Bool {
+ get { false }
+ set {}
+ }
+}
+
+@MainActor
+private final class ReviewMonitorReviewChatTableRowView: NSTableRowView {
+ override var isEmphasized: Bool {
+ get { false }
+ set {}
+ }
+}
+
+@MainActor
+private final class ReviewMonitorReviewChatCellView: NSTableCellView {
+ private var hostingView: NSHostingView?
+ private weak var boundChat: CodexChat?
+
+ override init(frame frameRect: NSRect) {
+ super.init(frame: frameRect)
+ configureHierarchy()
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ nil
+ }
+
+ func configure(with chat: CodexChat) {
+ guard boundChat !== chat else {
+ return
+ }
+ objectValue = chat
+ boundChat = chat
+ render(chat)
+ }
+
+ private func configureHierarchy() {
+ translatesAutoresizingMaskIntoConstraints = false
+ }
+
+ private func render(_ chat: CodexChat) {
+ toolTip = chat.workspace?.url.path ?? chat.preview ?? chat.title
+ if let hostingView {
+ if hostingView.rootView.chat !== chat {
+ hostingView.rootView.chat = chat
+ }
+ return
+ }
+
+ let hostingView = NSHostingView(
+ rootView: ReviewMonitorChatRowView(chat: chat)
+ )
+ hostingView.sizingOptions = []
+ hostingView.translatesAutoresizingMaskIntoConstraints = false
+ hostingView.setAccessibilityIdentifier("review-monitor.review-chat-row")
+ addSubview(hostingView)
+ NSLayoutConstraint.activate([
+ hostingView.topAnchor.constraint(equalTo: topAnchor),
+ hostingView.leadingAnchor.constraint(equalTo: leadingAnchor),
+ hostingView.trailingAnchor.constraint(equalTo: trailingAnchor),
+ hostingView.bottomAnchor.constraint(equalTo: bottomAnchor),
+ ])
+ self.hostingView = hostingView
+ }
+
+ #if DEBUG
+ func contentMinXForTesting(relativeTo view: NSView) -> CGFloat? {
+ guard let hostingView else {
+ return nil
+ }
+ layoutSubtreeIfNeeded()
+ return convert(hostingView.frame, to: view).minX
+ }
+ #endif
+}
+
+#if DEBUG
+ @MainActor
+ func makeReviewMonitorReviewChatCellViewForTesting(
+ chat: CodexChat
+ ) -> NSTableCellView {
+ let cellView = ReviewMonitorReviewChatCellView()
+ cellView.configure(with: chat)
+ return cellView
+ }
+
+ @MainActor
+ func configureReviewMonitorReviewChatCellViewForTesting(
+ _ cellView: NSTableCellView,
+ chat: CodexChat
+ ) {
+ guard let cellView = cellView as? ReviewMonitorReviewChatCellView else {
+ fatalError("Expected ReviewMonitorReviewChatCellView.")
+ }
+ cellView.configure(with: chat)
+ }
+#endif
+
+@MainActor
+private final class ReviewMonitorWorkspaceGroupCellView: NSTableCellView {
+ private let iconView = NSImageView()
+ private let titleLabel = NSTextField(labelWithString: "")
+ private let contentStack = NSStackView()
+ private enum BoundIdentity: Equatable {
+ case workspaceGroup(ObjectIdentifier)
+ case fallbackWorkspaceGroup(String)
+ }
+
+ private var boundIdentity: BoundIdentity?
+ private var modelObservation: PortableObservationTracking.Token?
+
+ override init(frame frameRect: NSRect) {
+ super.init(frame: frameRect)
+ configureHierarchy()
+ }
+
+ isolated deinit {
+ modelObservation?.cancel()
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ nil
+ }
+
+ func configure(workspaceGroup: CodexWorkspaceGroup) {
+ let identity = BoundIdentity.workspaceGroup(ObjectIdentifier(workspaceGroup))
+ guard boundIdentity != identity else {
+ return
+ }
+ objectValue = workspaceGroup
+ boundIdentity = identity
+ modelObservation?.cancel()
+ render(workspaceGroup: workspaceGroup)
+ modelObservation = withPortableContinuousObservation { [weak self, workspaceGroup] _ in
+ self?.render(workspaceGroup: workspaceGroup)
+ }
+ }
+
+ func configureFallbackWorkspaceGroup(title: String) {
+ let identity = BoundIdentity.fallbackWorkspaceGroup(title)
+ guard boundIdentity != identity else {
+ return
+ }
+ objectValue = title
+ boundIdentity = identity
+ modelObservation?.cancel()
+ render(title: title, toolTip: title, systemSymbolName: "folder")
+ }
+
+ func configure(title: String, toolTip: String, systemSymbolName: String = "folder") {
+ objectValue = title
+ boundIdentity = .fallbackWorkspaceGroup(title)
+ modelObservation?.cancel()
+ render(title: title, toolTip: toolTip, systemSymbolName: systemSymbolName)
+ }
+
+ private func render(title: String, toolTip: String, systemSymbolName: String) {
+ iconView.image = NSImage(systemSymbolName: systemSymbolName, accessibilityDescription: nil)
+ titleLabel.stringValue = title
+ self.toolTip = toolTip
+ }
+
+ private func render(workspaceGroup: CodexWorkspaceGroup) {
+ render(title: workspaceGroup.name, toolTip: workspaceGroup.name, systemSymbolName: "folder")
+ }
+
+ private func configureHierarchy() {
+ translatesAutoresizingMaskIntoConstraints = false
+
+ iconView.imageScaling = .scaleProportionallyDown
+ iconView.symbolConfiguration = .init(pointSize: 14, weight: .regular)
+ iconView.setContentHuggingPriority(.required, for: .horizontal)
+
+ titleLabel.font = .preferredFont(forTextStyle: .body)
+ titleLabel.textColor = .labelColor
+ titleLabel.lineBreakMode = .byTruncatingTail
+ titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+
+ imageView = iconView
+ textField = titleLabel
+
+ contentStack.translatesAutoresizingMaskIntoConstraints = false
+ contentStack.orientation = .horizontal
+ contentStack.alignment = .centerY
+ contentStack.spacing = 6
+ contentStack.detachesHiddenViews = true
+ contentStack.addArrangedSubview(iconView)
+ contentStack.addArrangedSubview(titleLabel)
+
+ addSubview(contentStack)
+ NSLayoutConstraint.activate([
+ contentStack.leadingAnchor.constraint(equalTo: leadingAnchor),
+ contentStack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor),
+ contentStack.topAnchor.constraint(equalTo: topAnchor, constant: 4),
+ contentStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
+ contentStack.centerYAnchor.constraint(equalTo: centerYAnchor),
+ ])
+ }
+
+ #if DEBUG
+ func contentMinXForTesting(relativeTo view: NSView) -> CGFloat {
+ layoutSubtreeIfNeeded()
+ return convert(contentStack.frame, to: view).minX
+ }
+ #endif
+}
diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorJobRowView.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorJobRowView.swift
deleted file mode 100644
index 8317c1c9..00000000
--- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorJobRowView.swift
+++ /dev/null
@@ -1,142 +0,0 @@
-import Foundation
-import SwiftUI
-import CodexReview
-
-struct ReviewMonitorJobRowView: View {
- var job: CodexReviewJob
-
- var body: some View {
- Label {
- VStack {
- HStack {
- Text(job.displayTitle)
- .truncationMode(.tail)
- Spacer(minLength: 0)
- TimerLabelView(job: job)
- .foregroundStyle(.secondary)
- .layoutPriority(1)
- }
- .lineLimit(1)
- HStack {
- if let model = job.core.run.model {
- Text(model)
- }
- if let subtitle = subtitleText {
- Text(subtitle)
- }
- Spacer(minLength: 0)
- }
- .textScale(.secondary)
- .foregroundStyle(.secondary)
- .lineLimit(1)
- }
- } icon: {
- ZStack {
- Image(systemName: "circle.fill")
- .foregroundStyle(.clear)
- if job.core.lifecycle.status == .running {
- ProgressView()
- .controlSize(.mini)
- }
- }
- .animation(.default, value: job.core.lifecycle.status)
- .padding(.leading, SidebarLayout.disclosureGutterWidth)
- }
- .transaction(value: job.id) { transaction in
- transaction.disablesAnimations = true
- }
- }
-
- private var subtitleText: String? {
- if job.core.output.hasFinalReview,
- let finalReview = job.core.output.lastAgentMessage?.trimmingCharacters(in: .whitespacesAndNewlines),
- finalReview.isEmpty == false
- {
- return finalReview
- }
- if job.core.lifecycle.status == .cancelled {
- let reviewText = job.reviewText.trimmingCharacters(in: .whitespacesAndNewlines)
- return reviewText.isEmpty ? nil : reviewText
- }
- if let errorMessage = job.core.lifecycle.errorMessage?.trimmingCharacters(in: .whitespacesAndNewlines),
- errorMessage.isEmpty == false
- {
- let summary = job.core.output.summary.trimmingCharacters(in: .whitespacesAndNewlines)
- return summary.isEmpty ? errorMessage : summary
- }
- guard let lastAgentMessage = job.core.output.lastAgentMessage?.trimmingCharacters(in: .whitespacesAndNewlines),
- lastAgentMessage.isEmpty == false
- else {
- return nil
- }
- return lastAgentMessage
- }
-}
-
-struct TimerLabelView: View {
- var job: CodexReviewJob
-
- var body: some View {
- if let startedAt = job.core.lifecycle.startedAt {
- Text(
- timerInterval: startedAt...(job.core.lifecycle.endedAt ?? .distantFuture),
- pauseTime: job.core.lifecycle.endedAt,
- countsDown: false,
- showsHours: true
- )
- .monospacedDigit()
- }
- }
- private func elapsedTimeText(startedAt: Date, referenceDate: Date) -> some View {
- let elapsedSeconds = max(0, referenceDate.timeIntervalSince(startedAt).rounded(.down))
- return Text(
- Duration.seconds(elapsedSeconds),
- format: .time(pattern: elapsedTimePattern(for: elapsedSeconds))
- )
- .monospacedDigit()
- .contentTransition(.numericText(value: elapsedSeconds))
- .animation(.default, value: elapsedSeconds)
- }
-
- private func elapsedTimePattern(for elapsedSeconds: TimeInterval) -> Duration.TimeFormatStyle.Pattern {
- elapsedSeconds >= 3600 ? .hourMinute : .minuteSecond
- }
-}
-
-extension ReviewJobState {
- var color: Color {
- switch self {
- case .queued: .gray
- case .running: .clear
- case .succeeded: .green
- case .failed: .red
- case .cancelled: .yellow
- }
- }
-}
-
-#if DEBUG
-#Preview {
- @Previewable @State var store = ReviewMonitorPreviewContent.makeStore()
- NavigationSplitView {
- List {
- ForEach(store.orderedWorkspaces, id: \.cwd) { workspace in
- Section(workspace.displayTitle) {
- ForEach(store.orderedJobs(in: workspace)) { job in
- NavigationLink{
-
- }label:{
- ReviewMonitorJobRowView(job: job)
- }
- }
- }
- }
- }
- .frame(minWidth: 320)
- } detail: {
- ContentUnavailableView {
- Text(verbatim: "Preview")
- }
- }
-}
-#endif
diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift
deleted file mode 100644
index 72c7cf4b..00000000
--- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift
+++ /dev/null
@@ -1,2865 +0,0 @@
-import AppKit
-import ObservationBridge
-import CodexReview
-import SwiftUI
-
-package enum SidebarLayout {
- static let disclosureGutterWidth: CGFloat = 16
-}
-
-@MainActor
-final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewDataSource, NSOutlineViewDelegate {
- enum SidebarKind: Equatable {
- case unavailable
- case empty
- case jobList
- case accountList
- }
-
- private enum Identifier {
- static let tableColumn = NSUserInterfaceItemIdentifier("ReviewMonitorJobs.Column")
- static let jobCell = NSUserInterfaceItemIdentifier("ReviewMonitorJobs.JobCell")
- static let workspaceCell = NSUserInterfaceItemIdentifier("ReviewMonitorJobs.WorkspaceCell")
- }
-
- private enum DragType {
- static let sidebarItem = NSPasteboard.PasteboardType("dev.codexreviewmcp.sidebar-item")
- }
-
- private struct SidebarRowHeights {
- let workspace: CGFloat
- let job: CGFloat
-
- @MainActor
- static func measure() -> SidebarRowHeights {
- SidebarRowHeights(
- workspace: measuredWorkspaceRowHeight(),
- job: measuredJobRowHeight()
- )
- }
-
- @MainActor
- private static func measuredWorkspaceRowHeight() -> CGFloat {
- let cellView = ReviewMonitorWorkspaceCellView()
- cellView.configure(CodexReviewWorkspace(cwd: "/tmp/workspace-alpha"))
- return ceil(cellView.fittingSize.height)
- }
-
- @MainActor
- private static func measuredJobRowHeight() -> CGFloat {
- let job = CodexReviewJob(
- id: "row-height-measurement",
- sessionID: "row-height-measurement",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- core: .init(
- run: .init(model: "gpt-5.5"),
- lifecycle: .init(
- status: .running,
- startedAt: Date(timeIntervalSince1970: 0)
- ),
- output: .init(
- summary: "",
- lastAgentMessage: "Review output preview"
- )
- ),
- logEntries: []
- )
- let cellView = ReviewMonitorJobCellView()
- cellView.configure(with: job)
- return ceil(cellView.fittingSize.height)
- }
- }
-
- private enum SidebarDragPayload: Codable, Equatable {
- case workspaceSection(id: String)
- case job(id: String, cwd: String)
- }
-
- private struct SidebarResolvedDrop {
- enum Operation {
- case none
- case reorderWorkspaceSection(id: String, cwds: [String], beforeCWD: String?, displayIndex: Int)
- case reorderJob(id: String, cwd: String, beforeJobID: String?, displayIndex: Int)
- }
-
- let operation: Operation
- let dropItem: Any?
- let dropChildIndex: Int
- }
-
- private struct SidebarWorkspaceTopology {
- let workspace: CodexReviewWorkspace
- let jobs: [CodexReviewJob]
- }
-
- private struct SidebarWorkspaceDropDestination {
- let rootInsertionIndex: Int
- }
-
- private struct SidebarJobDropDestination {
- let workspace: CodexReviewWorkspace
- let childIndex: Int
- let movesToFullOrderEnd: Bool
- }
-
- private struct DisplayedSidebarTopologySnapshot {
- let rootTopologies: [SidebarRootTopology]
-
- func rootIndex(forRootItem rootItem: AnyObject) -> Int? {
- rootTopologies.firstIndex { $0.item === rootItem }
- }
-
- func workspaceBeforeCWD(
- movingRootItem: AnyObject,
- rootInsertionIndex rawRootInsertionIndex: Int
- ) -> String? {
- let displayDestinationIndex = displayDestinationIndex(
- movingRootItem: movingRootItem,
- rootInsertionIndex: rawRootInsertionIndex
- )
- let remainingTopologies = rootTopologies.filter { $0.item !== movingRootItem }
- guard displayDestinationIndex < remainingTopologies.count else {
- return nil
- }
- return remainingTopologies[displayDestinationIndex].workspaces.first?.cwd
- }
-
- func displayDestinationIndex(
- movingRootItem: AnyObject,
- rootInsertionIndex rawRootInsertionIndex: Int
- ) -> Int {
- let sourceRootIndex = rootIndex(forRootItem: movingRootItem) ?? rootTopologies.count
- let remainingRootCount = max(0, rootTopologies.count - 1)
- let rootInsertionIndex = max(0, min(rawRootInsertionIndex, rootTopologies.count))
- let displayDestinationIndex = rootInsertionIndex > sourceRootIndex
- ? rootInsertionIndex - 1
- : rootInsertionIndex
- return max(0, min(displayDestinationIndex, remainingRootCount))
- }
- }
-
- private final class SidebarWorkspaceSection: Hashable {
- let id: String
- var title: String
- var workspaces: [CodexReviewWorkspace]
- var jobs: [CodexReviewJob]
- var isExpanded: Bool
-
- init(
- id: String,
- title: String,
- workspaces: [CodexReviewWorkspace],
- jobs: [CodexReviewJob]
- ) {
- self.id = id
- self.title = title
- self.workspaces = workspaces
- self.jobs = jobs
- self.isExpanded = true
- }
-
- var selection: ReviewMonitorWorkspaceSectionSelection {
- ReviewMonitorWorkspaceSectionSelection(
- id: id,
- title: title,
- workspaceCWDs: workspaces.map(\.cwd)
- )
- }
-
- static func == (lhs: SidebarWorkspaceSection, rhs: SidebarWorkspaceSection) -> Bool {
- lhs.id == rhs.id
- }
-
- func hash(into hasher: inout Hasher) {
- hasher.combine(id)
- }
- }
-
- private struct SidebarRootTopology {
- let item: AnyObject
- let workspaces: [CodexReviewWorkspace]
- let jobs: [CodexReviewJob]
- }
-
- private enum SidebarMutationAnimation {
- static let duration: TimeInterval = 0.18
- static let insertionOptions: NSTableView.AnimationOptions = [.effectFade, .slideDown]
- static let removalOptions: NSTableView.AnimationOptions = [.effectFade, .slideUp]
- }
-
- private let store: CodexReviewStore
- private let uiState: ReviewMonitorUIState
- private let scrollView = NSScrollView()
- private let outlineView = ReviewMonitorSidebarOutlineView()
- private let accountsViewController: ReviewMonitorAccountsViewController
- private let emptyStateViewController = PlaceholderViewController(content: .noReviewJobs)
- private let unavailableView: NSHostingView
- private let rowHeights: SidebarRowHeights
-
- private var sidebarKindObservation: PortableObservationTracking.Token?
- private var sidebarTopologyObservation: PortableObservationTracking.Token?
- private var sidebarFilterObservation: PortableObservationTracking.Token?
- private var workspaceSectionIdentitiesByCWD: [String: ReviewMonitorWorkspaceSectionIdentity] = [:]
- private var workspaceSectionsByID: [String: SidebarWorkspaceSection] = [:]
- private var currentRootTopologies: [SidebarRootTopology] = []
- private var isReconcilingSelection = false
-#if DEBUG
- private var fullReloadCountForTesting = 0
- private var workspaceReloadCountForTesting = 0
- private var incrementalMoveCountForTesting = 0
- private var incrementalMembershipChangeCountForTesting = 0
-#endif
-
- init(store: CodexReviewStore, uiState: ReviewMonitorUIState) {
- self.store = store
- self.uiState = uiState
- self.accountsViewController = ReviewMonitorAccountsViewController(store: store)
- self.unavailableView = NSHostingView(rootView: MCPServerUnavailableView(store: store))
- self.rowHeights = SidebarRowHeights.measure()
- super.init(nibName: nil, bundle: nil)
- }
-
- @available(*, unavailable)
- required init?(coder: NSCoder) {
- nil
- }
-
- isolated deinit {
- sidebarKindObservation?.cancel()
- sidebarTopologyObservation?.cancel()
- sidebarFilterObservation?.cancel()
- }
-
- override func loadView() {
- view = NSView(frame: .zero)
- }
-
- override func viewDidLoad() {
- super.viewDidLoad()
- configureHierarchy()
- configureOutlineView()
- bindObservation()
- }
-
- override func viewDidLayout() {
- super.viewDidLayout()
- }
-
- private func configureHierarchy() {
- scrollView.translatesAutoresizingMaskIntoConstraints = false
- scrollView.drawsBackground = false
- scrollView.borderType = .noBorder
- scrollView.hasVerticalScroller = true
- scrollView.autohidesScrollers = true
-
- view.addSubview(scrollView)
- addChild(emptyStateViewController)
- emptyStateViewController.view.translatesAutoresizingMaskIntoConstraints = false
- emptyStateViewController.view.isHidden = true
- view.addSubview(emptyStateViewController.view)
- addChild(accountsViewController)
- accountsViewController.view.translatesAutoresizingMaskIntoConstraints = false
- accountsViewController.view.isHidden = true
- view.addSubview(accountsViewController.view)
- unavailableView.translatesAutoresizingMaskIntoConstraints = false
- unavailableView.isHidden = true
- view.addSubview(unavailableView)
-
- NSLayoutConstraint.activate([
- scrollView.topAnchor.constraint(equalTo: view.topAnchor),
- scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
- scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
-
- emptyStateViewController.view.topAnchor.constraint(equalTo: view.topAnchor),
- emptyStateViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- emptyStateViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
- emptyStateViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
-
- accountsViewController.view.topAnchor.constraint(equalTo: view.topAnchor),
- accountsViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- accountsViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
- accountsViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
-
- unavailableView.topAnchor.constraint(equalTo: view.topAnchor),
- unavailableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- unavailableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
- unavailableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
- ])
- }
-
- private func configureOutlineView() {
- let tableColumn = NSTableColumn(identifier: Identifier.tableColumn)
- tableColumn.resizingMask = .autoresizingMask
-
- outlineView.frame = NSRect(origin: .zero, size: scrollView.contentSize)
- outlineView.autoresizingMask = [.width]
- outlineView.addTableColumn(tableColumn)
- outlineView.outlineTableColumn = tableColumn
- outlineView.headerView = nil
- outlineView.style = .sourceList
- outlineView.indentationPerLevel = SidebarLayout.disclosureGutterWidth
- outlineView.indentationMarkerFollowsCell = false
- outlineView.rowSizeStyle = .custom
- outlineView.rowHeight = rowHeights.job
- outlineView.usesAutomaticRowHeights = false
- outlineView.floatsGroupRows = false
- outlineView.backgroundColor = .clear
- outlineView.usesAlternatingRowBackgroundColors = false
- outlineView.intercellSpacing = NSSize(width: 0, height: 12)
- outlineView.allowsEmptySelection = true
- outlineView.allowsMultipleSelection = false
- outlineView.setAccessibilityIdentifier("review-monitor.job-list")
- outlineView.registerForDraggedTypes([DragType.sidebarItem])
- outlineView.setDraggingSourceOperationMask(.move, forLocal: true)
- outlineView.setDraggingSourceOperationMask([], forLocal: false)
- outlineView.draggingDestinationFeedbackStyle = .sourceList
- outlineView.dataSource = self
- outlineView.delegate = self
- outlineView.contextMenuProvider = { [weak self] point in
- self?.makeContextMenu(at: point)
- }
- outlineView.draggingExitedHandler = { [weak self] in
- self?.clearDropTarget()
- }
-
- scrollView.documentView = outlineView
- }
-
- private func bindObservation() {
- sidebarKindObservation?.cancel()
- sidebarTopologyObservation?.cancel()
- sidebarFilterObservation?.cancel()
-
- sidebarKindObservation = withPortableContinuousObservation { [weak self, uiState, store] _ in
- let sidebarSelection = uiState.sidebarSelection
- let serverState = store.serverState
- let hasReviewJobs = store.hasReviewJobs
- let hasWorkspaces = store.workspaces.isEmpty == false
- guard let self else {
- return
- }
- self.applySidebarKind(Self.sidebarKind(
- sidebarSelection: sidebarSelection,
- serverState: serverState,
- hasReviewJobs: hasReviewJobs,
- hasWorkspaces: hasWorkspaces
- ))
- }
-
- let initialFilter = uiState.sidebarJobFilter
- sidebarFilterObservation = withPortableContinuousObservation { [weak self, uiState] event in
- let filter = uiState.sidebarJobFilter
- guard event.kind != .initial else {
- return
- }
- let animatedInitialDelivery = true
- Task { @MainActor [weak self] in
- self?.bindSidebarStoreTopologyObservation(
- filter: filter,
- animatedInitialDelivery: animatedInitialDelivery
- )
- }
- }
- bindSidebarStoreTopologyObservation(
- filter: initialFilter,
- animatedInitialDelivery: false
- )
- }
-
- private func bindSidebarStoreTopologyObservation(
- filter: SidebarJobFilter,
- animatedInitialDelivery: Bool
- ) {
- sidebarTopologyObservation?.cancel()
- sidebarTopologyObservation = withPortableContinuousObservation { [weak self] event in
- guard let self else {
- return
- }
- let workspaceTopologies = self.sidebarWorkspaceTopologies()
- let rootTopologies = self.sidebarRootTopologies(
- from: workspaceTopologies,
- filter: filter
- )
- let animated = event.kind == .initial ? animatedInitialDelivery : true
- self.applySidebarTopology(
- rootTopologies,
- animated: animated
- )
- }
- }
-
- private var sidebarKind: SidebarKind {
- Self.sidebarKind(
- sidebarSelection: uiState.sidebarSelection,
- serverState: store.serverState,
- hasReviewJobs: store.hasReviewJobs,
- hasWorkspaces: store.workspaces.isEmpty == false
- )
- }
-
- private static func sidebarKind(
- sidebarSelection: SidebarPickerSelection?,
- serverState: CodexReviewServerState,
- hasReviewJobs: Bool,
- hasWorkspaces: Bool
- ) -> SidebarKind {
- if sidebarSelection == .account {
- return .accountList
- }
- switch serverState {
- case .failed, .starting, .stopped:
- return .unavailable
- case .running:
- break
- }
- let hasSidebarContent = hasReviewJobs || hasWorkspaces
- return hasSidebarContent ? .jobList : .empty
- }
-
- private func sidebarWorkspaceTopologies() -> [SidebarWorkspaceTopology] {
- store.orderedWorkspaces.map { workspace in
- SidebarWorkspaceTopology(
- workspace: workspace,
- jobs: store.orderedJobs(in: workspace)
- )
- }
- }
-
- private func sidebarRootTopologies(
- from workspaceTopologies: [SidebarWorkspaceTopology],
- filter: SidebarJobFilter
- ) -> [SidebarRootTopology] {
- var topologiesBySectionID: [String: [SidebarWorkspaceTopology]] = [:]
- var sectionIdentityByID: [String: ReviewMonitorWorkspaceSectionIdentity] = [:]
- var sectionOrder: [String] = []
-
- for topology in workspaceTopologies {
- let identity = workspaceSectionIdentity(for: topology.workspace)
- if topologiesBySectionID[identity.id] == nil {
- sectionOrder.append(identity.id)
- }
- sectionIdentityByID[identity.id] = identity
- topologiesBySectionID[identity.id, default: []].append(topology)
- }
-
- var renderedSectionIDs: Set = []
- let rootTopologies = sectionOrder.compactMap { sectionID -> SidebarRootTopology? in
- guard let topologies = topologiesBySectionID[sectionID],
- let identity = sectionIdentityByID[sectionID]
- else {
- return nil
- }
-
- renderedSectionIDs.insert(identity.id)
- let sectionJobs = Self.visibleJobs(
- in: topologies.flatMap(\.jobs),
- filter: filter
- )
- let section = workspaceSection(
- identity: identity,
- workspaces: topologies.map(\.workspace),
- jobs: sectionJobs
- )
- return SidebarRootTopology(
- item: section,
- workspaces: section.workspaces,
- jobs: section.jobs
- )
- }
- workspaceSectionsByID = workspaceSectionsByID.filter { renderedSectionIDs.contains($0.key) }
- return rootTopologies
- }
-
- private func workspaceSectionIdentity(for workspace: CodexReviewWorkspace) -> ReviewMonitorWorkspaceSectionIdentity {
- if let identity = workspaceSectionIdentitiesByCWD[workspace.cwd] {
- return identity
- }
- let identity = ReviewMonitorWorkspaceSectioning.identity(for: workspace.cwd)
- workspaceSectionIdentitiesByCWD[workspace.cwd] = identity
- return identity
- }
-
- private func workspaceSection(
- identity: ReviewMonitorWorkspaceSectionIdentity,
- workspaces: [CodexReviewWorkspace],
- jobs: [CodexReviewJob]
- ) -> SidebarWorkspaceSection {
- if let section = workspaceSectionsByID[identity.id] {
- section.title = identity.title
- section.workspaces = workspaces
- section.jobs = jobs
- return section
- }
-
- let section = SidebarWorkspaceSection(
- id: identity.id,
- title: identity.title,
- workspaces: workspaces,
- jobs: jobs
- )
- workspaceSectionsByID[identity.id] = section
- return section
- }
-
- private func applySidebarTopology(
- _ rootTopologies: [SidebarRootTopology],
- animated: Bool
- ) {
- let shouldAnimate = animated && shouldAnimateSidebarMutations
- currentRootTopologies = rootTopologies
- applyRootMembershipChange(
- rootTopologies.map(\.item),
- animated: shouldAnimate
- )
- applyJobMembershipChange(rootTopologies, animated: shouldAnimate)
- scheduleOutlineSelectionReconciliation()
- }
-
- private var shouldAnimateSidebarMutations: Bool {
- view.window != nil && NSWorkspace.shared.accessibilityDisplayShouldReduceMotion == false
- }
-
- private func applyRootMembershipChange(
- _ rootItems: [AnyObject],
- animated: Bool
- ) {
- let currentRootItems = displayedRootItems()
- let insertedRootItems = applyMembershipChange(
- currentItems: currentRootItems,
- targetItems: rootItems,
- parent: nil,
- animated: animated
- )
- applyStoredExpansionState(for: insertedRootItems)
- }
-
- private func applyJobMembershipChange(
- _ rootTopologies: [SidebarRootTopology],
- animated: Bool
- ) {
- let rootItems = rootTopologies.map(\.item)
- for rootItem in displayedRootItems() {
- guard let topology = rootTopologies.first(where: { $0.item === rootItem }) else {
- continue
- }
- let displayedJobs = displayedJobs(inRootItem: rootItem)
- let targetJobs = topology.jobs
- guard hasSameIdentityOrder(displayedJobs, targetJobs) == false else {
- continue
- }
- if outlineView.isItemExpanded(rootItem) {
- applyMembershipChange(
- currentItems: displayedJobs,
- targetItems: targetJobs,
- parent: rootItem,
- animated: animated
- )
- continue
- }
- reloadRootItem(rootItem, allRootItems: rootItems)
- }
- }
-
- private func reloadOutline(rootItems: [AnyObject]) {
- clearSelectionIfNeeded(for: workspaces())
-
-#if DEBUG
- fullReloadCountForTesting += 1
-#endif
- isReconcilingSelection = true
- outlineView.reloadData()
- applyStoredExpansionState(for: rootItems)
- reconcileOutlineSelection()
- isReconcilingSelection = false
- }
-
- private func applyStoredExpansionState(for rootItems: [AnyObject]) {
- for rootItem in rootItems {
- setRootItem(rootItem, expanded: rootItemIsExpanded(rootItem))
- }
- }
-
- private func setWorkspace(_ workspace: CodexReviewWorkspace, expanded: Bool) {
- guard let rootItem = rootItem(containing: workspace) else {
- return
- }
- setRootItem(rootItem, expanded: expanded)
- }
-
- private func setRootItem(_ rootItem: AnyObject, expanded: Bool) {
- guard row(forRootItem: rootItem) != nil else {
- return
- }
- let outlineIsExpanded = outlineView.isItemExpanded(rootItem)
- if expanded && outlineIsExpanded == false {
- outlineView.expandItem(rootItem)
- } else if expanded == false && outlineIsExpanded {
- outlineView.collapseItem(rootItem)
- }
- }
-
- private func rootItemIsExpanded(_ rootItem: AnyObject) -> Bool {
- workspaceSection(from: rootItem)?.isExpanded ?? true
- }
-
- private func reloadRootItem(
- _ rootItem: AnyObject,
- allRootItems: [AnyObject]
- ) {
- guard let rootItem = allRootItems.first(where: { $0 === rootItem })
- else {
- return
- }
-
-#if DEBUG
- workspaceReloadCountForTesting += 1
-#endif
- isReconcilingSelection = true
- outlineView.reloadItem(rootItem, reloadChildren: true)
- setRootItem(rootItem, expanded: rootItemIsExpanded(rootItem))
- isReconcilingSelection = false
- }
-
- private func scheduleOutlineSelectionReconciliation() {
- Task { @MainActor [weak self] in
- self?.reconcileSelectionAfterOutlineMutation()
- }
- }
-
- private func reconcileSelectionAfterOutlineMutation() {
- let wasReconcilingSelection = isReconcilingSelection
- isReconcilingSelection = true
- reconcileOutlineSelection()
- isReconcilingSelection = wasReconcilingSelection
- }
-
- private func applySidebarKind(_ kind: SidebarKind) {
- switch kind {
- case .unavailable:
- unavailableView.isHidden = false
- scrollView.isHidden = true
- emptyStateViewController.view.isHidden = true
- accountsViewController.view.isHidden = true
- case .empty:
- unavailableView.isHidden = true
- scrollView.isHidden = true
- emptyStateViewController.view.isHidden = false
- accountsViewController.view.isHidden = true
- case .jobList:
- unavailableView.isHidden = true
- scrollView.isHidden = false
- emptyStateViewController.view.isHidden = true
- accountsViewController.view.isHidden = true
- case .accountList:
- unavailableView.isHidden = true
- scrollView.isHidden = true
- emptyStateViewController.view.isHidden = true
- accountsViewController.view.isHidden = false
- }
- }
-
- private func reconcileOutlineSelection() {
- guard let selection = uiState.selection else {
- if outlineView.selectedRow != -1 {
- outlineView.deselectAll(nil)
- }
- return
- }
-
- switch selection {
- case .workspaceSection(let selectedSection):
- guard let currentSection = workspaceSection(id: selectedSection.id) else {
- uiState.selection = nil
- outlineView.deselectAll(nil)
- return
- }
-
- if currentSection.selection != selectedSection {
- uiState.selection = .workspaceSection(currentSection.selection)
- }
-
- guard let row = row(forRootItem: currentSection) else {
- return
- }
- guard outlineView.selectedRow != row else {
- return
- }
- outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
-
- case .job(let selectedJob):
- guard let currentJob = job(withID: selectedJob.id) else {
- uiState.selection = nil
- outlineView.deselectAll(nil)
- return
- }
-
- if currentJob !== selectedJob {
- uiState.selection = .job(currentJob)
- }
-
- guard let row = row(forJobID: currentJob.id) else {
- return
- }
-
- guard outlineView.selectedRow != row else {
- return
- }
- outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
- }
- }
-
- private func updateSelectionFromOutlineView() {
- guard isReconcilingSelection == false else {
- return
- }
- guard outlineView.selectedRow != -1 else {
- if selectionStillExists(uiState.selection) {
- return
- }
- uiState.selection = nil
- return
- }
- let item = outlineView.item(atRow: outlineView.selectedRow)
- if let section = workspaceSection(from: item) {
- uiState.selection = .workspaceSection(section.selection)
- } else if let job = job(from: item) {
- uiState.selection = .job(job)
- } else {
- uiState.selection = nil
- }
- }
-
- private func triggerCancellation(for job: CodexReviewJob) {
- Task { @MainActor [weak self] in
- guard let self else {
- return
- }
- await performCancellation(for: job)
- }
- }
-
- private func toggleWorkspaceExpansion(_ workspace: CodexReviewWorkspace) {
- guard let rootItem = rootItem(containing: workspace) else {
- return
- }
- setRootItem(rootItem, expanded: outlineView.isItemExpanded(rootItem) == false)
- }
-
- private func restoreSelectedJobRowAfterExpansion(of section: SidebarWorkspaceSection) {
- guard let selectedJob = uiState.selectedJobEntry,
- section.workspaces.contains(where: { $0.cwd == selectedJob.cwd })
- else {
- return
- }
- let selectedJobID = selectedJob.id
- DispatchQueue.main.async { [weak self, weak section] in
- guard let self,
- let section,
- section.isExpanded,
- self.uiState.selectedJobEntry?.id == selectedJobID,
- let row = self.row(forJobID: selectedJobID),
- self.outlineView.selectedRow != row
- else {
- return
- }
- self.isReconcilingSelection = true
- self.outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
- self.isReconcilingSelection = false
- }
- }
-
- private func makeContextMenu(at point: NSPoint) -> NSMenu? {
- let row = outlineView.row(at: point)
- guard row != -1,
- let job = job(atRow: row)
- else {
- return nil
- }
-
- let menu = NSMenu()
- menu.autoenablesItems = false
-
- let cancelItem = NSMenuItem(
- title: "Cancel",
- action: #selector(handleCancelMenuItem(_:)),
- keyEquivalent: ""
- )
- cancelItem.target = self
- cancelItem.representedObject = job
- cancelItem.isEnabled = job.isTerminal == false && job.cancellationRequested == false
- menu.addItem(cancelItem)
- return menu
- }
-
- @objc
- private func handleCancelMenuItem(_ sender: NSMenuItem) {
- guard let job = sender.representedObject as? CodexReviewJob else {
- return
- }
- triggerCancellation(for: job)
- }
-
- private func requestCancellation(for job: CodexReviewJob) async throws {
- guard job.isTerminal == false,
- job.cancellationRequested == false
- else {
- return
- }
- _ = try await store.cancelReview(
- jobID: job.id,
- sessionID: job.sessionID,
- cancellation: .userInterface()
- )
- }
-
- private func performCancellation(for job: CodexReviewJob) async {
- do {
- try await requestCancellation(for: job)
- } catch {
- handleCancellationFailure(error, for: job)
- }
- }
-
- private func handleCancellationFailure(_ error: Error, for job: CodexReviewJob) {
- let description = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
- let message = description.isEmpty ? "Failed to cancel review." : description
- try? store.recordCancellationFailure(
- jobID: job.id,
- sessionID: job.sessionID,
- message: message
- )
- }
-
- private func clearSelectionIfNeeded(for workspaces: [CodexReviewWorkspace]) {
- guard selectionStillExists(uiState.selection, in: workspaces) == false else {
- return
- }
- uiState.selection = nil
- if outlineView.selectedRow != -1 {
- outlineView.deselectAll(nil)
- }
- }
-
- @discardableResult
- private func applyMembershipChange(
- currentItems: [Item],
- targetItems: [Item],
- parent: Any?,
- animated: Bool
- ) -> [Item] {
- guard hasSameIdentityOrder(currentItems, targetItems) == false else {
- return []
- }
-
- var insertedItems: [Item] = []
- NSAnimationContext.runAnimationGroup { context in
- context.duration = animated ? SidebarMutationAnimation.duration : 0
- context.allowsImplicitAnimation = animated
-
- var visibleItems = currentItems
- outlineView.beginUpdates()
- defer {
- outlineView.endUpdates()
- }
-
- for sourceIndex in visibleItems.indices.reversed() {
- guard targetItems.contains(where: { $0 === visibleItems[sourceIndex] }) == false else {
- continue
- }
- outlineView.removeItems(
- at: IndexSet(integer: sourceIndex),
- inParent: parent,
- withAnimation: animated ? SidebarMutationAnimation.removalOptions : []
- )
-#if DEBUG
- incrementalMembershipChangeCountForTesting += 1
-#endif
- visibleItems.remove(at: sourceIndex)
- }
-
- for targetIndex in targetItems.indices {
- let targetItem = targetItems[targetIndex]
- if visibleItems.contains(where: { $0 === targetItem }) {
- continue
- }
- let insertionIndex = min(targetIndex, visibleItems.count)
-
- outlineView.insertItems(
- at: IndexSet(integer: insertionIndex),
- inParent: parent,
- withAnimation: animated ? SidebarMutationAnimation.insertionOptions : []
- )
-#if DEBUG
- incrementalMembershipChangeCountForTesting += 1
-#endif
- visibleItems.insert(targetItem, at: insertionIndex)
- insertedItems.append(targetItem)
- }
-
- for targetIndex in targetItems.indices {
- guard targetIndex < visibleItems.count else {
- continue
- }
- let targetItem = targetItems[targetIndex]
- if visibleItems[targetIndex] === targetItem {
- continue
- }
- guard let sourceIndex = visibleItems.firstIndex(where: { $0 === targetItem }) else {
- continue
- }
- outlineView.moveItem(
- at: sourceIndex,
- inParent: parent,
- to: targetIndex,
- inParent: parent
- )
-#if DEBUG
- incrementalMoveCountForTesting += 1
-#endif
- let movedItem = visibleItems.remove(at: sourceIndex)
- visibleItems.insert(movedItem, at: targetIndex)
- }
- }
- return insertedItems
- }
-
- private func hasSameIdentityOrder(_ lhs: [Item], _ rhs: [Item]) -> Bool {
- lhs.count == rhs.count && zip(lhs, rhs).allSatisfy { $0 === $1 }
- }
-
- private func moveWorkspaceSectionInOutline(id: String, toRootIndex destinationIndex: Int) {
- guard let section = workspaceSection(id: id) else {
- return
- }
- moveRootItemInOutline(section, toRootIndex: destinationIndex)
- }
-
- private func moveRootItemInOutline(_ rootItem: AnyObject, toRootIndex destinationIndex: Int) {
- let currentRootItems = displayedRootItems()
- guard let sourceIndex = currentRootItems.firstIndex(where: { $0 === rootItem }),
- sourceIndex != destinationIndex
- else {
- return
- }
- moveOutlineItem(from: sourceIndex, to: destinationIndex, parent: nil)
- }
-
- private func moveJobInOutline(id: String, in workspace: CodexReviewWorkspace, toIndex destinationIndex: Int) {
- let currentJobs = displayedJobs(in: workspace)
- guard let sourceIndex = currentJobs.firstIndex(where: { $0.id == id }),
- let parentItem = rootItem(containing: workspace)
- else {
- return
- }
- let rootJobs = displayedJobs(inRootItem: parentItem)
- guard let sourceRootIndex = rootJobs.firstIndex(where: { $0.id == id }),
- let workspaceRootStartIndex = rootJobs.firstIndex(where: { $0.cwd == workspace.cwd })
- else {
- return
- }
- let destinationRootIndex = workspaceRootStartIndex + destinationIndex
- guard sourceIndex != destinationIndex,
- sourceRootIndex != destinationRootIndex
- else {
- return
- }
- moveOutlineItem(from: sourceRootIndex, to: destinationRootIndex, parent: parentItem)
- }
-
- private func moveOutlineItem(from sourceIndex: Int, to destinationIndex: Int, parent: Any?) {
- let animated = shouldAnimateSidebarMutations
- NSAnimationContext.runAnimationGroup { context in
- context.duration = animated ? SidebarMutationAnimation.duration : 0
- context.allowsImplicitAnimation = animated
-
- outlineView.beginUpdates()
- outlineView.moveItem(
- at: sourceIndex,
- inParent: parent,
- to: destinationIndex,
- inParent: parent
- )
-#if DEBUG
- incrementalMoveCountForTesting += 1
-#endif
- outlineView.endUpdates()
- }
- reconcileSelectionAfterOutlineMutation()
- }
-
- private func displayedRootItems() -> [AnyObject] {
- (0.. [CodexReviewJob] {
- (0.. [CodexReviewJob] {
- (0.. [CodexReviewJob] {
- guard let section = workspaceSection(containing: workspace) else {
- return []
- }
- return section.jobs.filter { $0.cwd == workspace.cwd }
- }
-
- private static func visibleJobs(
- in jobs: [CodexReviewJob],
- filter: SidebarJobFilter
- ) -> [CodexReviewJob] {
- guard filter.isActive else {
- return jobs
- }
-
- let latestFinishedJob = filter.contains(.latestFinished)
- ? latestFinishedJob(in: jobs)
- : nil
- return jobs.filter { job in
- if filter.contains(.running),
- job.core.lifecycle.status.isTerminal == false
- {
- return true
- }
- return latestFinishedJob.map { $0 === job } ?? false
- }
- }
-
- private static func latestFinishedJob(in orderedJobs: [CodexReviewJob]) -> CodexReviewJob? {
- var latestJob: CodexReviewJob?
- var latestDate = Date.distantPast
- for job in orderedJobs {
- guard job.core.lifecycle.status.isTerminal else {
- continue
- }
- let finishedAt = job.core.lifecycle.endedAt
- ?? job.core.lifecycle.startedAt
- ?? .distantPast
- if latestJob == nil || finishedAt > latestDate {
- latestJob = job
- latestDate = finishedAt
- }
- }
- return latestJob
- }
-
- private func workspaceSection(from item: Any?) -> SidebarWorkspaceSection? {
- item as? SidebarWorkspaceSection
- }
-
- private func job(from item: Any?) -> CodexReviewJob? {
- item as? CodexReviewJob
- }
-
- private func shouldAllowSelection(of item: Any?) -> Bool {
- workspaceSection(from: item) != nil || job(from: item) != nil
- }
-
- private func workspaces() -> [CodexReviewWorkspace] {
- store.orderedWorkspaces
- }
-
- private func filteredJobCount(in workspace: CodexReviewWorkspace) -> Int {
- visibleJobs(in: workspace).count
- }
-
- private func job(atRow row: Int) -> CodexReviewJob? {
- guard row >= 0,
- let item = outlineView.item(atRow: row)
- else {
- return nil
- }
- return job(from: item)
- }
-
- private func row(for workspace: CodexReviewWorkspace) -> Int? {
- guard let rootItem = rootItem(containing: workspace) else {
- return nil
- }
- let row = outlineView.row(forItem: rootItem)
- return row == -1 ? nil : row
- }
-
- private func row(forRootItem rootItem: AnyObject) -> Int? {
- let row = outlineView.row(forItem: rootItem)
- return row == -1 ? nil : row
- }
-
- private func workspace(cwd: String) -> CodexReviewWorkspace? {
- store.workspace(cwd: cwd)
- }
-
- private func workspaceSection(id: String) -> SidebarWorkspaceSection? {
- workspaceSectionsByID[id]
- }
-
- private func workspaceSection(containing workspace: CodexReviewWorkspace) -> SidebarWorkspaceSection? {
- rootItem(containing: workspace).flatMap { workspaceSection(from: $0) }
- }
-
- private func rootItem(containing workspace: CodexReviewWorkspace) -> AnyObject? {
- workspaceSectionsByID.values.first { section in
- section.workspaces.contains(where: { $0.cwd == workspace.cwd })
- }
- }
-
- private func rootItem(containing job: CodexReviewJob) -> AnyObject? {
- guard let workspace = workspace(containing: job) else {
- return nil
- }
- return rootItem(containing: workspace)
- }
-
- private func row(forJobID jobID: String) -> Int? {
- guard let job = job(withID: jobID) else {
- return nil
- }
- let row = outlineView.row(forItem: job)
- return row == -1 ? nil : row
- }
-
- private func containsJob(id: String) -> Bool {
- job(withID: id) != nil
- }
-
- private func containsJob(id: String, in workspaces: [CodexReviewWorkspace]) -> Bool {
- job(withID: id, in: workspaces) != nil
- }
-
- private func selectionStillExists(_ selection: ReviewMonitorSelection?) -> Bool {
- selectionStillExists(selection, in: workspaces())
- }
-
- private func selectionStillExists(
- _ selection: ReviewMonitorSelection?,
- in workspaces: [CodexReviewWorkspace]
- ) -> Bool {
- guard let selection else {
- return true
- }
- switch selection {
- case .workspaceSection(let section):
- return workspaceSectionsByID[section.id] != nil
- || section.workspaceCWDs.contains { cwd in
- workspaces.contains(where: { $0.cwd == cwd })
- }
- case .job(let job):
- return containsJob(id: job.id, in: workspaces)
- }
- }
-
- private func job(withID id: String) -> CodexReviewJob? {
- job(withID: id, in: workspaces())
- }
-
- private func job(withID id: String, in workspaces: [CodexReviewWorkspace]) -> CodexReviewJob? {
- guard let job = store.job(id: id),
- workspaces.contains(where: { $0.cwd == job.cwd })
- else {
- return nil
- }
- return job
- }
-
- private func workspace(containing job: CodexReviewJob) -> CodexReviewWorkspace? {
- store.workspace(containing: job)
- }
-
- private func workspaceReorderWouldChange(cwds: [String], beforeCWD: String?) -> Bool {
- let cwdSet = Set(cwds)
- guard cwdSet.isEmpty == false,
- beforeCWD.map({ cwdSet.contains($0) }) != true
- else {
- return false
- }
-
- let ordered = workspaces()
- let moving = ordered.filter { cwdSet.contains($0.cwd) }
- guard moving.isEmpty == false else {
- return false
- }
-
- let remaining = ordered.filter { cwdSet.contains($0.cwd) == false }
- let destinationIndex: Int
- if let beforeCWD {
- guard let beforeIndex = remaining.firstIndex(where: { $0.cwd == beforeCWD }) else {
- return false
- }
- destinationIndex = beforeIndex
- } else {
- destinationIndex = remaining.count
- }
-
- var reordered = remaining
- reordered.insert(contentsOf: moving, at: destinationIndex)
- return reordered.count == ordered.count &&
- zip(reordered, ordered).contains { $0.0 !== $0.1 }
- }
-
- private func dragPayload(for item: Any) -> SidebarDragPayload? {
- if let section = workspaceSection(from: item) {
- return .workspaceSection(id: section.id)
- }
- if let job = job(from: item) {
- return .job(id: job.id, cwd: job.cwd)
- }
- return nil
- }
-
- private func makePasteboardItem(for payload: SidebarDragPayload) -> NSPasteboardItem? {
- guard let data = try? JSONEncoder().encode(payload) else {
- return nil
- }
- let item = NSPasteboardItem()
- item.setData(data, forType: DragType.sidebarItem)
- return item
- }
-
- private func dragPayload(from draggingInfo: any NSDraggingInfo) -> SidebarDragPayload? {
- guard let draggingSource = draggingInfo.draggingSource as? NSOutlineView,
- draggingSource === outlineView,
- let data = draggingInfo.draggingPasteboard.data(forType: DragType.sidebarItem)
- else {
- return nil
- }
- return try? JSONDecoder().decode(SidebarDragPayload.self, from: data)
- }
-
- private func clearDropTarget() {
- outlineView.setDropItem(nil, dropChildIndex: NSOutlineViewDropOnItemIndex)
- }
-
- private func resolvedDrop(
- for payload: SidebarDragPayload,
- draggingLocation: NSPoint? = nil,
- proposedItem: Any?,
- proposedChildIndex index: Int
- ) -> SidebarResolvedDrop? {
- switch payload {
- case .workspaceSection(let id):
- resolvedWorkspaceSectionDrop(
- id: id,
- draggingLocation: draggingLocation,
- proposedItem: proposedItem,
- proposedChildIndex: index
- )
- case .job(let id, let cwd):
- resolvedJobDrop(
- id: id,
- cwd: cwd,
- draggingLocation: draggingLocation,
- proposedItem: proposedItem,
- proposedChildIndex: index
- )
- }
- }
-
- private func resolvedWorkspaceSectionDrop(
- id: String,
- draggingLocation: NSPoint?,
- proposedItem: Any?,
- proposedChildIndex index: Int
- ) -> SidebarResolvedDrop? {
- let snapshot = DisplayedSidebarTopologySnapshot(rootTopologies: currentRootTopologies)
- guard let section = workspaceSection(id: id),
- snapshot.rootIndex(forRootItem: section) != nil,
- let destination = resolvedWorkspaceDropDestination(
- draggingLocation: draggingLocation,
- proposedItem: proposedItem,
- proposedChildIndex: index
- )
- else {
- return nil
- }
-
- let beforeCWD = snapshot.workspaceBeforeCWD(
- movingRootItem: section,
- rootInsertionIndex: destination.rootInsertionIndex
- )
- let cwds = section.workspaces.map(\.cwd)
- guard workspaceReorderWouldChange(cwds: cwds, beforeCWD: beforeCWD) else {
- return nil
- }
- let displayDestinationIndex = snapshot.displayDestinationIndex(
- movingRootItem: section,
- rootInsertionIndex: destination.rootInsertionIndex
- )
- let operation: SidebarResolvedDrop.Operation = .reorderWorkspaceSection(
- id: section.id,
- cwds: cwds,
- beforeCWD: beforeCWD,
- displayIndex: displayDestinationIndex
- )
- return SidebarResolvedDrop(
- operation: operation,
- dropItem: nil,
- dropChildIndex: destination.rootInsertionIndex
- )
- }
-
- private func resolvedWorkspaceDropDestination(
- draggingLocation: NSPoint?,
- proposedItem: Any?,
- proposedChildIndex index: Int
- ) -> SidebarWorkspaceDropDestination? {
- if proposedItem == nil,
- index != NSOutlineViewDropOnItemIndex
- {
- return workspaceRootDropDestination(rootInsertionIndex: index)
- }
-
- if let blankAreaDestination = blankAreaWorkspaceDropDestination(draggingLocation: draggingLocation) {
- return blankAreaDestination
- }
-
- if let targetSection = workspaceSection(from: proposedItem),
- let targetRootIndex = rootIndex(forRootItem: targetSection)
- {
- return workspaceRootDropDestination(
- rootInsertionIndex: workspaceRootInsertionIndex(
- aroundRootItem: targetSection,
- defaultIndex: targetRootIndex,
- draggingLocation: draggingLocation
- )
- )
- }
-
- if let targetJob = job(from: proposedItem),
- let targetWorkspace = workspace(containing: targetJob),
- let targetSection = workspaceSection(containing: targetWorkspace),
- let targetRootIndex = rootIndex(forRootItem: targetSection)
- {
- return workspaceRootDropDestination(
- rootInsertionIndex: workspaceRootInsertionIndex(
- aroundRootItem: targetSection,
- defaultIndex: targetRootIndex,
- draggingLocation: draggingLocation
- )
- )
- }
-
- return nil
- }
-
- private func resolvedWorkspaceInsertionIndex(
- draggingLocation: NSPoint?,
- proposedItem: Any?,
- proposedChildIndex index: Int
- ) -> Int? {
- resolvedWorkspaceDropDestination(
- draggingLocation: draggingLocation,
- proposedItem: proposedItem,
- proposedChildIndex: index
- )?.rootInsertionIndex
- }
-
- private func blankAreaWorkspaceDropDestination(
- draggingLocation: NSPoint?
- ) -> SidebarWorkspaceDropDestination? {
- guard let draggingLocation,
- outlineView.numberOfRows > 0
- else {
- return nil
- }
-
- let firstRowRect = outlineView.rect(ofRow: 0)
- if draggingLocation.y < firstRowRect.minY {
- return workspaceRootDropDestination(rootInsertionIndex: 0)
- }
-
- let lastRowRect = outlineView.rect(ofRow: outlineView.numberOfRows - 1)
- if draggingLocation.y > lastRowRect.maxY {
- return workspaceRootDropDestination(rootInsertionIndex: currentRootTopologies.count)
- }
-
- return nil
- }
-
- private func workspaceRootInsertionIndex(
- aroundRootItem rootItem: AnyObject,
- defaultIndex: Int,
- draggingLocation: NSPoint?
- ) -> Int {
- guard let draggingLocation,
- let sectionRect = rootSectionRect(forRootItem: rootItem)
- else {
- return max(0, min(defaultIndex, currentRootTopologies.count))
- }
-
- let insertionIndex = draggingLocation.y < sectionRect.midY
- ? defaultIndex
- : defaultIndex + 1
- return max(0, min(insertionIndex, currentRootTopologies.count))
- }
-
- private func workspaceRootDropDestination(rootInsertionIndex index: Int) -> SidebarWorkspaceDropDestination {
- let rootInsertionIndex = max(0, min(index, currentRootTopologies.count))
- return SidebarWorkspaceDropDestination(rootInsertionIndex: rootInsertionIndex)
- }
-
- private func rootIndex(containing workspace: CodexReviewWorkspace) -> Int? {
- guard let rootItem = rootItem(containing: workspace) else {
- return nil
- }
- return rootIndex(forRootItem: rootItem)
- }
-
- private func rootIndex(forRootItem rootItem: AnyObject) -> Int? {
- currentRootTopologies.firstIndex { $0.item === rootItem }
- }
-
- private func workspaceSectionRect(for workspace: CodexReviewWorkspace) -> NSRect? {
- guard let rootItem = rootItem(containing: workspace) else {
- return nil
- }
- return rootSectionRect(forRootItem: rootItem)
- }
-
- private func rootSectionRect(forRootItem rootItem: AnyObject) -> NSRect? {
- guard let rootRow = row(forRootItem: rootItem) else {
- return nil
- }
-
- var sectionRect = outlineView.rect(ofRow: rootRow)
- let isExpanded = workspaceSection(from: rootItem)?.isExpanded ?? false
- guard isExpanded,
- let lastJob = displayedJobs(inRootItem: rootItem).last,
- let lastJobRow = row(forJobID: lastJob.id)
- else {
- return sectionRect
- }
-
- sectionRect = sectionRect.union(outlineView.rect(ofRow: lastJobRow))
- return sectionRect
- }
-
- private func resolvedJobDrop(
- id: String,
- cwd: String,
- draggingLocation: NSPoint?,
- proposedItem: Any?,
- proposedChildIndex index: Int
- ) -> SidebarResolvedDrop? {
- guard uiState.sidebarJobFilter.allowsJobReordering else {
- return nil
- }
- guard let destination = resolvedJobDropDestination(
- draggingLocation: draggingLocation,
- proposedItem: proposedItem,
- proposedChildIndex: index,
- sourceCWD: cwd
- ),
- destination.workspace.cwd == cwd
- else {
- return nil
- }
-
- let orderedJobs = store.orderedJobs(in: destination.workspace)
- let destinationVisibleJobs = visibleJobs(in: destination.workspace)
- guard let visibleSourceIndex = destinationVisibleJobs.firstIndex(where: { $0.id == id }) else {
- return nil
- }
-
- let visibleInsertionIndex = max(0, min(destination.childIndex, destinationVisibleJobs.count))
- let beforeJobID = jobBeforeID(
- movingJobID: id,
- visibleInsertionIndex: visibleInsertionIndex,
- visibleJobs: destinationVisibleJobs,
- orderedJobs: orderedJobs,
- movesToFullOrderEnd: destination.movesToFullOrderEnd
- )
- let displayDestinationIndex = visibleInsertionIndex > visibleSourceIndex
- ? visibleInsertionIndex - 1
- : visibleInsertionIndex
- let clampedDisplayDestinationIndex = max(0, min(displayDestinationIndex, destinationVisibleJobs.count - 1))
- let displayOrderChanges = clampedDisplayDestinationIndex != visibleSourceIndex
- guard destination.movesToFullOrderEnd || displayOrderChanges else {
- return nil
- }
- guard jobReorderWouldChange(id: id, inWorkspace: cwd, beforeJobID: beforeJobID) else {
- return nil
- }
- let operation: SidebarResolvedDrop.Operation = .reorderJob(
- id: id,
- cwd: cwd,
- beforeJobID: beforeJobID,
- displayIndex: clampedDisplayDestinationIndex
- )
- let dropPresentation = jobDropPresentation(
- for: destination.workspace,
- childIndex: visibleInsertionIndex
- )
- return SidebarResolvedDrop(
- operation: operation,
- dropItem: dropPresentation.item,
- dropChildIndex: dropPresentation.childIndex
- )
- }
-
- private func jobBeforeID(
- movingJobID: String,
- visibleInsertionIndex: Int,
- visibleJobs: [CodexReviewJob],
- orderedJobs: [CodexReviewJob],
- movesToFullOrderEnd: Bool
- ) -> String? {
- if movesToFullOrderEnd {
- return nil
- }
- if visibleInsertionIndex < visibleJobs.count {
- let targetJobID = visibleJobs[visibleInsertionIndex].id
- return targetJobID == movingJobID ? movingJobID : targetJobID
- }
- guard let lastVisibleJob = visibleJobs.last,
- let lastVisibleIndex = orderedJobs.firstIndex(where: { $0 === lastVisibleJob })
- else {
- return nil
- }
- let nextIndex = lastVisibleIndex + 1
- guard nextIndex < orderedJobs.count,
- orderedJobs[nextIndex].id != movingJobID
- else {
- return nil
- }
- return orderedJobs[nextIndex].id
- }
-
- private func jobReorderWouldChange(
- id: String,
- inWorkspace cwd: String,
- beforeJobID: String?
- ) -> Bool {
- guard beforeJobID != id else {
- return false
- }
- let ordered = store.orderedJobs(inWorkspace: cwd)
- guard let movingJob = ordered.first(where: { $0.id == id }) else {
- return false
- }
- let remaining = ordered.filter { $0 !== movingJob }
- let destinationIndex: Int
- if let beforeJobID {
- guard let beforeIndex = remaining.firstIndex(where: { $0.id == beforeJobID }) else {
- return false
- }
- destinationIndex = beforeIndex
- } else {
- destinationIndex = remaining.count
- }
- var reordered = remaining
- reordered.insert(movingJob, at: destinationIndex)
- return reordered.count == ordered.count &&
- zip(reordered, ordered).contains { $0.0 !== $0.1 }
- }
-
- private func resolvedJobDropDestination(
- draggingLocation: NSPoint?,
- proposedItem: Any?,
- proposedChildIndex index: Int,
- sourceCWD: String
- ) -> SidebarJobDropDestination? {
- if let destination = blankAreaJobDropDestination(
- draggingLocation: draggingLocation,
- sourceCWD: sourceCWD
- ) {
- return destination
- }
-
- if let section = workspaceSection(from: proposedItem),
- index != NSOutlineViewDropOnItemIndex
- {
- return resolvedJobDropDestination(
- in: section,
- sourceCWD: sourceCWD,
- proposedRootChildIndex: index
- )
- }
-
- guard let targetJob = job(from: proposedItem),
- index == NSOutlineViewDropOnItemIndex
- else {
- return nil
- }
- return resolvedJobDropDestination(
- around: targetJob,
- draggingLocation: draggingLocation,
- sourceCWD: sourceCWD
- )
- }
-
- private func resolvedJobDropDestination(
- around targetJob: CodexReviewJob,
- draggingLocation: NSPoint?,
- sourceCWD: String
- ) -> SidebarJobDropDestination? {
- guard let draggingLocation,
- let targetWorkspace = workspace(containing: targetJob),
- let targetSection = workspaceSection(containing: targetWorkspace),
- let targetWorkspaceRootStartIndex = targetSection.jobs.firstIndex(where: { $0.cwd == targetWorkspace.cwd }),
- let targetJobIndex = visibleJobs(in: targetWorkspace).firstIndex(where: { $0.id == targetJob.id }),
- let targetRow = row(forJobID: targetJob.id)
- else {
- return nil
- }
-
- let targetRowRect = outlineView.rect(ofRow: targetRow)
- let rootInsertionIndex = targetWorkspaceRootStartIndex
- + targetJobIndex
- + (draggingLocation.y < targetRowRect.midY ? 0 : 1)
- return resolvedJobDropDestination(
- in: targetSection,
- sourceCWD: sourceCWD,
- proposedRootChildIndex: rootInsertionIndex
- )
- }
-
- private func blankAreaJobDropDestination(
- draggingLocation: NSPoint?,
- sourceCWD: String
- ) -> SidebarJobDropDestination? {
- guard let draggingLocation,
- outlineView.numberOfRows > 0
- else {
- return nil
- }
-
- let lastRowRect = outlineView.rect(ofRow: outlineView.numberOfRows - 1)
- guard draggingLocation.y > lastRowRect.maxY,
- let workspace = workspace(cwd: sourceCWD),
- let sourceSection = workspaceSection(containing: workspace),
- currentRootTopologies.last?.item === sourceSection,
- sourceSection.jobs.last?.cwd == sourceCWD
- else {
- return nil
- }
-
- return SidebarJobDropDestination(
- workspace: workspace,
- childIndex: visibleJobs(in: workspace).count,
- movesToFullOrderEnd: true
- )
- }
-
- private func resolvedJobDropDestination(
- in section: SidebarWorkspaceSection,
- sourceCWD: String,
- proposedRootChildIndex index: Int
- ) -> SidebarJobDropDestination? {
- guard let workspace = section.workspaces.first(where: { $0.cwd == sourceCWD }),
- let workspaceRootStartIndex = section.jobs.firstIndex(where: { $0.cwd == sourceCWD })
- else {
- return nil
- }
-
- let workspaceJobCount = visibleJobs(in: workspace).count
- let workspaceRootEndIndex = workspaceRootStartIndex + workspaceJobCount
- let rootInsertionIndex = max(0, min(index, section.jobs.count))
- guard rootInsertionIndex >= workspaceRootStartIndex,
- rootInsertionIndex <= workspaceRootEndIndex
- else {
- return nil
- }
-
- return SidebarJobDropDestination(
- workspace: workspace,
- childIndex: rootInsertionIndex - workspaceRootStartIndex,
- movesToFullOrderEnd: false
- )
- }
-
- private func jobDropPresentation(
- for workspace: CodexReviewWorkspace,
- childIndex: Int
- ) -> (item: Any?, childIndex: Int) {
- guard let rootItem = rootItem(containing: workspace),
- let section = workspaceSection(from: rootItem),
- let workspaceRootStartIndex = section.jobs.firstIndex(where: { $0.cwd == workspace.cwd })
- else {
- return (workspace, childIndex)
- }
-
- let rootChildIndex = workspaceRootStartIndex + childIndex
- return (section, max(0, min(rootChildIndex, section.jobs.count)))
- }
-
- @discardableResult
- private func applyResolvedDrop(_ resolvedDrop: SidebarResolvedDrop) -> Bool {
- switch resolvedDrop.operation {
- case .none:
- return false
- case .reorderWorkspaceSection(let id, let cwds, let beforeCWD, let displayIndex):
- guard store.reorderWorkspaces(cwds: cwds, beforeCWD: beforeCWD) else {
- return false
- }
- moveWorkspaceSectionInOutline(id: id, toRootIndex: displayIndex)
- return true
- case .reorderJob(let id, let cwd, let beforeJobID, let displayIndex):
- let workspace = workspace(cwd: cwd)
- guard store.reorderJob(id: id, inWorkspace: cwd, beforeJobID: beforeJobID) else {
- return false
- }
- if let workspace {
- moveJobInOutline(id: id, in: workspace, toIndex: displayIndex)
- }
- return true
- }
- }
-
- func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
- guard let item else {
- return currentRootTopologies.count
- }
- if let section = workspaceSection(from: item) {
- return section.jobs.count
- }
- return 0
- }
-
- func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any {
- guard let item else {
- return currentRootTopologies[index].item
- }
- if let section = workspaceSection(from: item) {
- return section.jobs[index]
- }
- fatalError("Unsupported sidebar item.")
- }
-
- func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool {
- workspaceSection(from: item) != nil
- }
-
- func outlineView(_ outlineView: NSOutlineView, isGroupItem item: Any) -> Bool {
- false
- }
-
- func outlineView(_ outlineView: NSOutlineView, heightOfRowByItem item: Any) -> CGFloat {
- if workspaceSection(from: item) != nil {
- return rowHeights.workspace
- }
- if job(from: item) != nil {
- return rowHeights.job
- }
- return rowHeights.job
- }
-
- func outlineView(_ outlineView: NSOutlineView, shouldCollapseItem item: Any) -> Bool {
- true
- }
-
- func outlineView(_ outlineView: NSOutlineView, shouldExpandItem item: Any) -> Bool {
- true
- }
-
- func outlineView(_ outlineView: NSOutlineView, shouldSelectItem item: Any) -> Bool {
- shouldAllowSelection(of: item)
- }
-
- func outlineView(
- _ outlineView: NSOutlineView,
- pasteboardWriterForItem item: Any
- ) -> (any NSPasteboardWriting)? {
- guard let payload = dragPayload(for: item) else {
- return nil
- }
- return makePasteboardItem(for: payload)
- }
-
- func outlineView(
- _ outlineView: NSOutlineView,
- validateDrop info: any NSDraggingInfo,
- proposedItem item: Any?,
- proposedChildIndex index: Int
- ) -> NSDragOperation {
- let draggingLocation = outlineView.convert(info.draggingLocation, from: nil)
- guard let payload = dragPayload(from: info),
- let resolvedDrop = resolvedDrop(
- for: payload,
- draggingLocation: draggingLocation,
- proposedItem: item,
- proposedChildIndex: index
- )
- else {
- clearDropTarget()
- return []
- }
-
- outlineView.setDropItem(resolvedDrop.dropItem, dropChildIndex: resolvedDrop.dropChildIndex)
- return .move
- }
-
- func outlineView(
- _ outlineView: NSOutlineView,
- acceptDrop info: any NSDraggingInfo,
- item: Any?,
- childIndex index: Int
- ) -> Bool {
- defer {
- clearDropTarget()
- }
- let draggingLocation = outlineView.convert(info.draggingLocation, from: nil)
- guard let payload = dragPayload(from: info),
- let resolvedDrop = resolvedDrop(
- for: payload,
- draggingLocation: draggingLocation,
- proposedItem: item,
- proposedChildIndex: index
- )
- else {
- return false
- }
- info.animatesToDestination = false
- return applyResolvedDrop(resolvedDrop)
- }
-
- func outlineViewSelectionDidChange(_ notification: Notification) {
- updateSelectionFromOutlineView()
- }
-
- func outlineViewItemDidExpand(_ notification: Notification) {
- let item = notification.userInfo?["NSObject"]
- if let section = workspaceSection(from: item) {
- if section.isExpanded == false {
- section.isExpanded = true
- }
- restoreSelectedJobRowAfterExpansion(of: section)
- }
- }
-
- func outlineViewItemDidCollapse(_ notification: Notification) {
- let item = notification.userInfo?["NSObject"]
- if let section = workspaceSection(from: item) {
- if section.isExpanded {
- section.isExpanded = false
- }
- }
- }
-
- func outlineView(_ outlineView: NSOutlineView, rowViewForItem item: Any) -> NSTableRowView? {
- if workspaceSection(from: item) != nil {
- return ReviewMonitorWorkspaceRowView()
- }
- if job(from: item) != nil {
- return ReviewMonitorJobTableRowView()
- }
- return nil
- }
-
- func outlineView(
- _ outlineView: NSOutlineView,
- viewFor tableColumn: NSTableColumn?,
- item: Any
- ) -> NSView? {
- if let section = workspaceSection(from: item) {
- let view = (outlineView.makeView(withIdentifier: Identifier.workspaceCell, owner: self) as? ReviewMonitorWorkspaceCellView)
- ?? ReviewMonitorWorkspaceCellView()
- view.identifier = Identifier.workspaceCell
- view.objectValue = section
- view.configure(title: section.title, toolTip: section.selection.subtitle)
- return view
- }
-
- if let job = job(from: item) {
- let view = (outlineView.makeView(withIdentifier: Identifier.jobCell, owner: self) as? ReviewMonitorJobCellView)
- ?? ReviewMonitorJobCellView()
- view.identifier = Identifier.jobCell
- view.configure(with: job)
- return view
- }
- return nil
- }
-
-}
-
-#if DEBUG
-@MainActor
-extension ReviewMonitorSidebarViewController {
- var sidebarKindObservationForTesting: PortableObservationTracking.Token? {
- sidebarKindObservation
- }
-
- var sidebarTopologyObservationForTesting: PortableObservationTracking.Token? {
- sidebarTopologyObservation
- }
-
- var sidebarFilterObservationForTesting: PortableObservationTracking.Token? {
- sidebarFilterObservation
- }
-
- var sidebarKindForTesting: SidebarKind {
- sidebarKind
- }
-
- var accountsViewControllerForTesting: ReviewMonitorAccountsViewController {
- accountsViewController.loadViewIfNeeded()
- return accountsViewController
- }
-
- var displayedSectionTitlesForTesting: [String] {
- var titles: [String] = []
- for row in 0.. Bool {
- workspaceSection(containing: workspace).map(shouldAllowSelection(of:)) ?? false
- }
-
- func displayedJobIDsForTesting(in workspace: CodexReviewWorkspace) -> [String] {
- var jobIDs: [String] = []
- for row in 0.. CGFloat? {
- guard let row = row(for: workspace) else {
- return nil
- }
- view.layoutSubtreeIfNeeded()
- return outlineView.rect(ofRow: row).height
- }
-
- func jobRowHeightForTesting(_ job: CodexReviewJob) -> CGFloat? {
- guard let row = row(forJobID: job.id) else {
- return nil
- }
- view.layoutSubtreeIfNeeded()
- return outlineView.rect(ofRow: row).height
- }
-
- func workspaceCellMinXForTesting(_ workspace: CodexReviewWorkspace) -> CGFloat? {
- guard let row = row(for: workspace) else {
- return nil
- }
- view.layoutSubtreeIfNeeded()
- guard let cellView = outlineView.view(
- atColumn: 0,
- row: row,
- makeIfNecessary: true
- ) as? ReviewMonitorWorkspaceCellView else {
- return nil
- }
- outlineView.layoutSubtreeIfNeeded()
- return cellView.contentMinXForTesting(relativeTo: outlineView)
- }
-
- func workspaceDisclosureMaxXForTesting(_ workspace: CodexReviewWorkspace) -> CGFloat? {
- guard let row = row(for: workspace) else {
- return nil
- }
- view.layoutSubtreeIfNeeded()
- outlineView.layoutSubtreeIfNeeded()
- let disclosureFrame = outlineView.frameOfOutlineCell(atRow: row)
- return disclosureFrame.width > 0 ? disclosureFrame.maxX : nil
- }
-
- func jobCellMinXForTesting(_ job: CodexReviewJob) -> CGFloat? {
- guard let row = row(forJobID: job.id) else {
- return nil
- }
- view.layoutSubtreeIfNeeded()
- guard let cellView = outlineView.view(
- atColumn: 0,
- row: row,
- makeIfNecessary: true
- ) as? ReviewMonitorJobCellView else {
- return nil
- }
- outlineView.layoutSubtreeIfNeeded()
- return cellView.contentMinXForTesting(relativeTo: outlineView)
- }
-
- func jobRowUsesReviewMonitorJobRowViewForTesting(_ job: CodexReviewJob) -> Bool {
- guard let row = row(forJobID: job.id),
- let cellView = outlineView.view(
- atColumn: 0,
- row: row,
- makeIfNecessary: true
- ) as? ReviewMonitorJobCellView
- else {
- return false
- }
- return cellView.isHostingReviewMonitorJobRowViewForTesting
- }
-
- func cancelJobForTesting(_ job: CodexReviewJob) async {
- await performCancellation(for: job)
- }
-
- func clickBlankAreaForTesting() {
- view.layoutSubtreeIfNeeded()
- let point = blankPointForTesting()
- precondition(
- outlineView.suppressesSelectionClearingForTesting(at: point),
- "Expected a blank click target outside any job item."
- )
- outlineView.mouseDown(with: mouseEventForTesting(at: point))
- }
-
- func clickWorkspaceHeaderForTesting(_ workspace: CodexReviewWorkspace) {
- view.layoutSubtreeIfNeeded()
- guard let row = row(for: workspace) else {
- preconditionFailure("Workspace row is not visible.")
- }
- outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
- }
-
- func presentContextMenuForTesting(
- for job: CodexReviewJob,
- presenter: @escaping (NSMenu) -> Void
- ) {
- view.layoutSubtreeIfNeeded()
- guard let row = row(forJobID: job.id) else {
- preconditionFailure("Job row is not visible.")
- }
- let rect = outlineView.rect(ofRow: row)
- let point = NSPoint(x: rect.midX, y: rect.midY)
- outlineView.presentContextMenuForTesting(at: point, presenter: presenter)
- }
-
- func focusSidebarForTesting() {
- _ = view.window?.makeFirstResponder(outlineView)
- }
-
- var sidebarHasFirstResponderForTesting: Bool {
- view.window?.firstResponder === outlineView
- }
-
- var isPresentingContextMenuForTesting: Bool {
- outlineView.isPresentingContextMenuForTesting
- }
-
- var acceptsFirstResponderForTesting: Bool {
- outlineView.acceptsFirstResponderForTesting
- }
-
- var hasTemporaryContextMenuForTesting: Bool {
- outlineView.menu != nil
- }
-
- func workspaceIsExpandedForTesting(_ workspace: CodexReviewWorkspace) -> Bool {
- workspaceSection(containing: workspace)?.isExpanded ?? false
- }
-
- func workspaceOutlineIsExpandedForTesting(_ workspace: CodexReviewWorkspace) -> Bool {
- guard let rootItem = rootItem(containing: workspace) else {
- return false
- }
- return outlineView.isItemExpanded(rootItem)
- }
-
- var selectedOutlineJobIDForTesting: String? {
- guard outlineView.selectedRow != -1 else {
- return nil
- }
- return job(atRow: outlineView.selectedRow)?.id
- }
-
- func toggleWorkspaceDisclosureForTesting(_ workspace: CodexReviewWorkspace) {
- guard row(for: workspace) != nil else {
- preconditionFailure("Workspace row is not visible.")
- }
- toggleWorkspaceExpansion(workspace)
- }
-
- func collapseWorkspaceInOutlineForTesting(_ workspace: CodexReviewWorkspace) {
- guard let rootItem = rootItem(containing: workspace),
- row(for: workspace) != nil
- else {
- preconditionFailure("Workspace row is not visible.")
- }
- outlineView.collapseItem(rootItem)
- }
-
- func expandWorkspaceInOutlineForTesting(_ workspace: CodexReviewWorkspace) {
- guard let rootItem = rootItem(containing: workspace),
- row(for: workspace) != nil
- else {
- preconditionFailure("Workspace row is not visible.")
- }
- outlineView.expandItem(rootItem)
- }
-
- func workspaceRowIsFloatingForTesting(_ workspace: CodexReviewWorkspace) -> Bool {
- guard let row = row(for: workspace),
- let rowView = outlineView.rowView(atRow: row, makeIfNecessary: true)
- else {
- return false
- }
- return rowView.isFloating
- }
-
- func scrollSidebarToOffsetForTesting(_ yOffset: CGFloat) {
- let clampedOffset = max(0, yOffset)
- scrollView.contentView.scroll(to: NSPoint(x: 0, y: clampedOffset))
- scrollView.reflectScrolledClipView(scrollView.contentView)
- view.layoutSubtreeIfNeeded()
- }
-
- var sidebarVisibleHeightForTesting: CGFloat {
- scrollView.documentVisibleRect.height
- }
-
- var safeAreaFrameForTesting: NSRect {
- view.safeAreaRect
- }
-
- var scrollViewFrameForTesting: NSRect {
- scrollView.frame
- }
-
- var sidebarDocumentHeightForTesting: CGFloat {
- outlineView.frame.height
- }
-
- var sidebarOutlineContentHeightForTesting: CGFloat {
- guard outlineView.numberOfRows > 0 else {
- return 0
- }
- return outlineView.rect(ofRow: outlineView.numberOfRows - 1).maxY
- }
-
- var sidebarMaximumVerticalScrollOffsetForTesting: CGFloat {
- max(0, sidebarDocumentHeightForTesting - sidebarVisibleHeightForTesting)
- }
-
- var sidebarVisibleRectForTesting: NSRect {
- scrollView.documentVisibleRect
- }
-
- var sidebarFirstRowRectForTesting: NSRect {
- guard outlineView.numberOfRows > 0 else {
- return .zero
- }
- return outlineView.rect(ofRow: 0)
- }
-
- var sidebarLastRowRectForTesting: NSRect {
- guard outlineView.numberOfRows > 0 else {
- return .zero
- }
- return outlineView.rect(ofRow: outlineView.numberOfRows - 1)
- }
-
- @discardableResult
- func performWorkspaceDropForTesting(
- _ workspace: CodexReviewWorkspace,
- toIndex index: Int
- ) -> Bool {
- guard let section = workspaceSection(containing: workspace),
- let resolvedDrop = resolvedDrop(
- for: .workspaceSection(id: section.id),
- proposedItem: nil,
- proposedChildIndex: index
- ) else {
- return false
- }
- return applyResolvedDrop(resolvedDrop)
- }
-
- @discardableResult
- func performWorkspaceDropForTesting(
- _ workspace: CodexReviewWorkspace,
- proposedWorkspace targetWorkspace: CodexReviewWorkspace
- ) -> Bool {
- guard let section = workspaceSection(containing: workspace),
- let targetSection = workspaceSection(containing: targetWorkspace),
- let resolvedDrop = resolvedDrop(
- for: .workspaceSection(id: section.id),
- proposedItem: targetSection,
- proposedChildIndex: NSOutlineViewDropOnItemIndex
- ) else {
- return false
- }
- return applyResolvedDrop(resolvedDrop)
- }
-
- func workspaceDropIsRejectedForTesting(
- _ workspace: CodexReviewWorkspace,
- proposedJob targetJob: CodexReviewJob
- ) -> Bool {
- guard let section = workspaceSection(containing: workspace) else {
- return true
- }
- return resolvedDrop(
- for: .workspaceSection(id: section.id),
- proposedItem: targetJob,
- proposedChildIndex: NSOutlineViewDropOnItemIndex
- ) == nil
- }
-
- @discardableResult
- func performWorkspaceDropForTesting(
- _ workspace: CodexReviewWorkspace,
- proposedJob targetJob: CodexReviewJob,
- hoveringBelowMidpoint: Bool
- ) -> Bool {
- guard let section = workspaceSection(containing: workspace),
- let targetRow = row(forJobID: targetJob.id)
- else {
- return false
- }
- let targetRowRect = outlineView.rect(ofRow: targetRow)
- let draggingLocation = NSPoint(
- x: targetRowRect.midX,
- y: hoveringBelowMidpoint ? targetRowRect.midY + 1 : targetRowRect.midY - 1
- )
- guard let resolvedDrop = resolvedDrop(
- for: .workspaceSection(id: section.id),
- draggingLocation: draggingLocation,
- proposedItem: targetJob,
- proposedChildIndex: NSOutlineViewDropOnItemIndex
- ) else {
- return false
- }
- return applyResolvedDrop(resolvedDrop)
- }
-
- func workspaceSectionCanStartDragForTesting(containing workspace: CodexReviewWorkspace) -> Bool {
- guard let section = workspaceSection(containing: workspace) else {
- return false
- }
- return dragPayload(for: section) != nil
- }
-
- @discardableResult
- func performWorkspaceSectionDropForTesting(
- containing workspace: CodexReviewWorkspace,
- toIndex index: Int
- ) -> Bool {
- guard let section = workspaceSection(containing: workspace),
- let resolvedDrop = resolvedDrop(
- for: .workspaceSection(id: section.id),
- proposedItem: nil,
- proposedChildIndex: index
- )
- else {
- return false
- }
- return applyResolvedDrop(resolvedDrop)
- }
-
- func workspaceInsertionIndexForTesting(
- _ workspace: CodexReviewWorkspace,
- hoveringBelowMidpoint: Bool
- ) -> Int? {
- guard let sectionRect = workspaceSectionRect(for: workspace) else {
- return nil
- }
- let point = NSPoint(
- x: sectionRect.midX,
- y: hoveringBelowMidpoint ? sectionRect.midY + 1 : sectionRect.midY - 1
- )
- return resolvedWorkspaceInsertionIndex(
- draggingLocation: point,
- proposedItem: workspaceSection(containing: workspace),
- proposedChildIndex: NSOutlineViewDropOnItemIndex
- )
- }
-
- func blankAreaWorkspaceInsertionIndexForTesting(atEnd: Bool) -> Int? {
- guard outlineView.numberOfRows > 0 else {
- return nil
- }
- let rowRect = outlineView.rect(ofRow: atEnd ? outlineView.numberOfRows - 1 : 0)
- let point = NSPoint(
- x: rowRect.midX,
- y: atEnd ? rowRect.maxY + 1 : rowRect.minY - 1
- )
- return resolvedWorkspaceInsertionIndex(
- draggingLocation: point,
- proposedItem: nil,
- proposedChildIndex: NSOutlineViewDropOnItemIndex
- )
- }
-
- @discardableResult
- func performJobDropForTesting(
- _ job: CodexReviewJob,
- proposedWorkspace: CodexReviewWorkspace,
- childIndex: Int
- ) -> Bool {
- guard let section = workspaceSection(containing: proposedWorkspace),
- let resolvedDrop = resolvedDrop(
- for: .job(id: job.id, cwd: job.cwd),
- proposedItem: section,
- proposedChildIndex: childIndex
- ) else {
- return false
- }
- return applyResolvedDrop(resolvedDrop)
- }
-
- @discardableResult
- func performJobDropForTesting(
- _ job: CodexReviewJob,
- proposedJob targetJob: CodexReviewJob,
- hoveringBelowMidpoint: Bool
- ) -> Bool {
- guard let targetRow = row(forJobID: targetJob.id) else {
- return false
- }
- let targetRowRect = outlineView.rect(ofRow: targetRow)
- let draggingLocation = NSPoint(
- x: targetRowRect.midX,
- y: hoveringBelowMidpoint ? targetRowRect.midY + 1 : targetRowRect.midY - 1
- )
- guard let resolvedDrop = resolvedDrop(
- for: .job(id: job.id, cwd: job.cwd),
- draggingLocation: draggingLocation,
- proposedItem: targetJob,
- proposedChildIndex: NSOutlineViewDropOnItemIndex
- ) else {
- return false
- }
- return applyResolvedDrop(resolvedDrop)
- }
-
- @discardableResult
- func performJobDropForTesting(
- _ job: CodexReviewJob,
- proposedWorkspaceSectionContaining workspace: CodexReviewWorkspace,
- childIndex: Int
- ) -> Bool {
- guard let section = workspaceSection(containing: workspace),
- let resolvedDrop = resolvedDrop(
- for: .job(id: job.id, cwd: job.cwd),
- proposedItem: section,
- proposedChildIndex: childIndex
- )
- else {
- return false
- }
- return applyResolvedDrop(resolvedDrop)
- }
-
- func jobDropIsRejectedForTesting(_ job: CodexReviewJob) -> Bool {
- resolvedDrop(
- for: .job(id: job.id, cwd: job.cwd),
- proposedItem: nil,
- proposedChildIndex: NSOutlineViewDropOnItemIndex
- ) == nil
- }
-
- @discardableResult
- func performJobBlankAreaDropForTesting(_ job: CodexReviewJob) -> Bool {
- guard outlineView.numberOfRows > 0,
- let workspace = workspace(cwd: job.cwd),
- let section = workspaceSection(containing: workspace)
- else {
- return false
- }
- let lastRowRect = outlineView.rect(ofRow: outlineView.numberOfRows - 1)
- let draggingLocation = NSPoint(x: lastRowRect.midX, y: lastRowRect.maxY + 1)
- guard let resolvedDrop = resolvedDrop(
- for: .job(id: job.id, cwd: job.cwd),
- draggingLocation: draggingLocation,
- proposedItem: section,
- proposedChildIndex: section.jobs.count
- ) else {
- return false
- }
- return applyResolvedDrop(resolvedDrop)
- }
-
- private func blankPointForTesting() -> NSPoint {
- let blankY: CGFloat
- if outlineView.numberOfRows > 0 {
- blankY = outlineView.rect(ofRow: outlineView.numberOfRows - 1).maxY + 10
- } else {
- blankY = outlineView.bounds.midY
- }
- let x = min(outlineView.bounds.maxX - 1, max(1, outlineView.bounds.midX))
- let y = min(outlineView.bounds.maxY - 1, max(1, blankY))
- return NSPoint(x: x, y: y)
- }
-
- private func mouseEventForTesting(at point: NSPoint) -> NSEvent {
- guard let window = view.window else {
- fatalError("Sidebar view controller must be attached to a window for click testing.")
- }
- let locationInWindow = outlineView.convert(point, to: nil)
- guard let event = NSEvent.mouseEvent(
- with: .leftMouseDown,
- location: locationInWindow,
- modifierFlags: [],
- timestamp: ProcessInfo.processInfo.systemUptime,
- windowNumber: window.windowNumber,
- context: nil,
- eventNumber: 0,
- clickCount: 1,
- pressure: 1
- ) else {
- fatalError("Failed to create a synthetic mouse event.")
- }
- return event
- }
-}
-#endif
-
-@MainActor
-private final class ReviewMonitorSidebarOutlineView: NSOutlineView {
- var contextMenuProvider: ((NSPoint) -> NSMenu?)?
- var draggingExitedHandler: (() -> Void)?
- private var isPresentingContextMenu = false
- private weak var contextMenuFirstResponder: NSResponder?
- private var previousContextMenu: NSMenu?
-
- override var acceptsFirstResponder: Bool {
- isPresentingContextMenu ? false : super.acceptsFirstResponder
- }
-
- @objc(_indentationForRow:withLevel:isSourceListGroupRow:)
- dynamic func indentationForRow(
- _ row: Int,
- withLevel level: Int,
- isSourceListGroupRow: Bool
- ) -> CGFloat {
- // Workspace rows keep AppKit's native disclosure gutter. Job rows do
- // not add outline-level indentation; the SwiftUI Label icon slot is
- // the visual indent.
- level <= 0 ? max(indentationPerLevel, 0) : 0
- }
-
- override func mouseDown(with event: NSEvent) {
- let point = convert(event.locationInWindow, from: nil)
- handlePrimaryInteraction(at: point) {
- super.mouseDown(with: event)
- }
- }
-
- override func rightMouseDown(with event: NSEvent) {
- let point = convert(event.locationInWindow, from: nil)
- guard let contextMenu = contextMenuProvider?(point) else {
- super.rightMouseDown(with: event)
- return
- }
-
- beginContextMenuPresentation(with: contextMenu)
- super.rightMouseDown(with: event)
-
- if isPresentingContextMenu {
- endContextMenuPresentation()
- }
- }
-
- override func draggingExited(_ sender: (any NSDraggingInfo)?) {
- draggingExitedHandler?()
- super.draggingExited(sender)
- }
-
- override func didCloseMenu(_ menu: NSMenu, with event: NSEvent?) {
- super.didCloseMenu(menu, with: event)
- guard isPresentingContextMenu else {
- return
- }
- endContextMenuPresentation()
- }
-
- private func shouldSuppressSelectionClearing(at point: NSPoint) -> Bool {
- guard selectedRow != -1 else {
- return false
- }
- return row(at: point) == -1
- }
-
- private func handlePrimaryInteraction(
- at point: NSPoint,
- action: () -> Void
- ) {
- guard shouldSuppressSelectionClearing(at: point) == false else {
- return
- }
-
- action()
- }
-
- private func isSidebarFirstResponder(_ responder: NSResponder) -> Bool {
- if responder === self {
- return true
- }
- guard let view = responder as? NSView else {
- return false
- }
- return view === self || view.isDescendant(of: self)
- }
-
- private func restoreFirstResponder(_ responder: NSResponder?) {
- guard let window else {
- return
- }
- if let view = responder as? NSView, view.window === window {
- _ = window.makeFirstResponder(view)
- return
- }
- _ = window.makeFirstResponder(self)
- }
-
- private func beginContextMenuPresentation(with contextMenu: NSMenu) {
- previousContextMenu = menu
- menu = contextMenu
- isPresentingContextMenu = true
-
- guard let window else {
- contextMenuFirstResponder = nil
- return
- }
-
- let previousFirstResponder = window.firstResponder
- guard previousFirstResponder.map(isSidebarFirstResponder(_:)) ?? false else {
- contextMenuFirstResponder = nil
- return
- }
-
- contextMenuFirstResponder = previousFirstResponder
- _ = window.makeFirstResponder(nil)
- }
-
- private func endContextMenuPresentation() {
- let previousFirstResponder = contextMenuFirstResponder
- let previousContextMenu = previousContextMenu
-
- contextMenuFirstResponder = nil
- self.previousContextMenu = nil
- isPresentingContextMenu = false
- menu = previousContextMenu
-
- guard let previousFirstResponder else {
- return
- }
- restoreFirstResponder(previousFirstResponder)
- }
-
-#if DEBUG
- func suppressesSelectionClearingForTesting(at point: NSPoint) -> Bool {
- shouldSuppressSelectionClearing(at: point)
- }
-
- func presentContextMenuForTesting(
- at point: NSPoint,
- presenter: @escaping (NSMenu) -> Void
- ) {
- guard window != nil else {
- fatalError("Sidebar outline view must be attached to a window for context menu testing.")
- }
- guard let contextMenu = contextMenuProvider?(point) else {
- return
- }
- beginContextMenuPresentation(with: contextMenu)
- presenter(contextMenu)
- if isPresentingContextMenu {
- endContextMenuPresentation()
- }
- }
-
- var isPresentingContextMenuForTesting: Bool {
- isPresentingContextMenu
- }
-
- var acceptsFirstResponderForTesting: Bool {
- acceptsFirstResponder
- }
-
-#endif
-}
-
-@MainActor
-private final class ReviewMonitorWorkspaceRowView: NSTableRowView {
- override var isEmphasized: Bool {
- get { false }
- set { }
- }
-}
-
-@MainActor
-private final class ReviewMonitorJobTableRowView: NSTableRowView {
- override var isEmphasized: Bool {
- get { false }
- set { }
- }
-}
-
-@MainActor
-private final class ReviewMonitorJobCellView: NSTableCellView {
- private var hostingView: NSHostingView?
-
- override init(frame frameRect: NSRect) {
- super.init(frame: frameRect)
- configureHierarchy()
- }
-
- @available(*, unavailable)
- required init?(coder: NSCoder) {
- nil
- }
-
- func configure(with job: CodexReviewJob) {
- objectValue = job
- toolTip = job.cwd
- if let hostingView {
- hostingView.rootView.job = job
- } else {
- let hostingView = NSHostingView(
- rootView: ReviewMonitorJobRowView(job: job)
- )
- hostingView.translatesAutoresizingMaskIntoConstraints = false
- hostingView.setAccessibilityIdentifier("review-monitor.job-row")
- addSubview(hostingView)
- NSLayoutConstraint.activate([
- hostingView.topAnchor.constraint(equalTo: topAnchor),
- hostingView.leadingAnchor.constraint(equalTo: leadingAnchor),
- hostingView.trailingAnchor.constraint(equalTo: trailingAnchor),
- hostingView.bottomAnchor.constraint(equalTo: bottomAnchor)
- ])
- self.hostingView = hostingView
- }
- }
-
- private func configureHierarchy() {
- translatesAutoresizingMaskIntoConstraints = false
- }
-
- #if DEBUG
- var isHostingReviewMonitorJobRowViewForTesting: Bool {
- hostingView != nil
- }
-
- var hostedJobIDForTesting: String? {
- hostingView?.rootView.job.id
- }
-
- var hostingViewIdentityForTesting: ObjectIdentifier? {
- hostingView.map(ObjectIdentifier.init)
- }
-
- func contentMinXForTesting(relativeTo view: NSView) -> CGFloat? {
- guard let hostingView else {
- return nil
- }
- layoutSubtreeIfNeeded()
- return convert(hostingView.frame, to: view).minX
- }
- #endif
-}
-
-#if DEBUG
-@MainActor
-func makeReviewMonitorJobCellViewForTesting(job: CodexReviewJob) -> NSTableCellView {
- let cellView = ReviewMonitorJobCellView()
- cellView.configure(with: job)
- return cellView
-}
-
-@MainActor
-func configureReviewMonitorJobCellViewForTesting(
- _ cellView: NSTableCellView,
- job: CodexReviewJob
-) {
- guard let cellView = cellView as? ReviewMonitorJobCellView else {
- fatalError("Expected ReviewMonitorJobCellView.")
- }
- cellView.configure(with: job)
-}
-
-@MainActor
-func reviewMonitorJobCellHostedJobIDForTesting(_ cellView: NSTableCellView) -> String? {
- guard let cellView = cellView as? ReviewMonitorJobCellView else {
- return nil
- }
- return cellView.hostedJobIDForTesting
-}
-
-@MainActor
-func reviewMonitorJobCellHostingViewIdentityForTesting(
- _ cellView: NSTableCellView
-) -> ObjectIdentifier? {
- guard let cellView = cellView as? ReviewMonitorJobCellView else {
- return nil
- }
- return cellView.hostingViewIdentityForTesting
-}
-#endif
-
-@MainActor
-private final class ReviewMonitorWorkspaceCellView: NSTableCellView {
- private let iconView = NSImageView()
- private let titleLabel = NSTextField(labelWithString: "")
- private let contentStack = NSStackView()
-
- override init(frame frameRect: NSRect) {
- super.init(frame: frameRect)
- configureHierarchy()
- }
-
- @available(*, unavailable)
- required init?(coder: NSCoder) {
- nil
- }
-
- func configure(_ workspace: CodexReviewWorkspace) {
- objectValue = workspace
- configure(title: workspace.displayTitle, toolTip: workspace.cwd)
- }
-
- func configure(title: String, toolTip: String) {
- iconView.image = NSImage(systemSymbolName: "folder", accessibilityDescription: nil)
- titleLabel.stringValue = title
- self.toolTip = toolTip
- }
-
- private func configureHierarchy() {
- translatesAutoresizingMaskIntoConstraints = false
-
- iconView.imageScaling = .scaleProportionallyDown
- iconView.symbolConfiguration = .init(pointSize: 14, weight: .regular)
- iconView.setContentHuggingPriority(.required, for: .horizontal)
-
- titleLabel.font = .preferredFont(forTextStyle: .body)
- titleLabel.textColor = .labelColor
- titleLabel.lineBreakMode = .byTruncatingTail
- titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
-
- imageView = iconView
- textField = titleLabel
-
- contentStack.translatesAutoresizingMaskIntoConstraints = false
- contentStack.orientation = .horizontal
- contentStack.alignment = .centerY
- contentStack.spacing = 6
- contentStack.detachesHiddenViews = true
- contentStack.addArrangedSubview(iconView)
- contentStack.addArrangedSubview(titleLabel)
-
- addSubview(contentStack)
- NSLayoutConstraint.activate([
- contentStack.leadingAnchor.constraint(equalTo: leadingAnchor),
- contentStack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor),
- contentStack.topAnchor.constraint(equalTo: topAnchor, constant: 4),
- contentStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
- contentStack.centerYAnchor.constraint(equalTo: centerYAnchor)
- ])
- }
-
- #if DEBUG
- func contentMinXForTesting(relativeTo view: NSView) -> CGFloat {
- layoutSubtreeIfNeeded()
- return convert(contentStack.frame, to: view).minX
- }
- #endif
-}
diff --git a/Sources/ReviewUI/Sidebar/SidebarJobFilterToolbarItem.swift b/Sources/ReviewUI/Sidebar/SidebarReviewChatFilterToolbarItem.swift
similarity index 77%
rename from Sources/ReviewUI/Sidebar/SidebarJobFilterToolbarItem.swift
rename to Sources/ReviewUI/Sidebar/SidebarReviewChatFilterToolbarItem.swift
index 76f133aa..3de73137 100644
--- a/Sources/ReviewUI/Sidebar/SidebarJobFilterToolbarItem.swift
+++ b/Sources/ReviewUI/Sidebar/SidebarReviewChatFilterToolbarItem.swift
@@ -2,12 +2,12 @@ import AppKit
import ObservationBridge
@MainActor
-final class ReviewMonitorSidebarJobFilterToolbarItem: NSToolbarItem {
+final class ReviewMonitorSidebarReviewChatFilterToolbarItem: NSToolbarItem {
private let uiState: ReviewMonitorUIState
private let filterMenu: NSMenu
private let menuFormItem: NSMenuItem
private let toolbarButton: NSButton
- private var filterMenuItems: [SidebarJobFilter: NSMenuItem] = [:]
+ private var filterMenuItems: [SidebarReviewChatFilter: NSMenuItem] = [:]
private var observation: PortableObservationTracking.Token?
init(
@@ -64,7 +64,7 @@ final class ReviewMonitorSidebarJobFilterToolbarItem: NSToolbarItem {
addMenuItem(for: .latestFinished)
}
- private func addMenuItem(for filter: SidebarJobFilter) {
+ private func addMenuItem(for filter: SidebarReviewChatFilter) {
let item = NSMenuItem(
title: String(localized: filter.localized),
action: #selector(handleFilterSelection(_:)),
@@ -79,11 +79,11 @@ final class ReviewMonitorSidebarJobFilterToolbarItem: NSToolbarItem {
private func bindObservation() {
observation?.cancel()
observation = withPortableContinuousObservation { [weak self, uiState] _ in
- self?.applySelection(uiState.sidebarJobFilter)
+ self?.applySelection(uiState.sidebarReviewChatFilter)
}
}
- private func applySelection(_ filter: SidebarJobFilter) {
+ private func applySelection(_ filter: SidebarReviewChatFilter) {
toolbarButton.state = filter.isActive ? .on : .off
menuFormItem.state = filter.isActive ? .on : .off
for (candidate, item) in filterMenuItems {
@@ -97,31 +97,31 @@ final class ReviewMonitorSidebarJobFilterToolbarItem: NSToolbarItem {
@objc
private func handleToolbarButton(_ sender: NSButton) {
- applySelection(uiState.sidebarJobFilter)
+ applySelection(uiState.sidebarReviewChatFilter)
sender.state = .on
filterMenu.popUp(
- positioning: positioningMenuItem(for: uiState.sidebarJobFilter),
+ positioning: positioningMenuItem(for: uiState.sidebarReviewChatFilter),
at: NSPoint(x: 0, y: sender.bounds.maxY),
in: sender
)
- applySelection(uiState.sidebarJobFilter)
+ applySelection(uiState.sidebarReviewChatFilter)
}
@objc
private func handleFilterSelection(_ sender: NSMenuItem) {
- guard let filter = sender.representedObject as? SidebarJobFilter else {
+ guard let filter = sender.representedObject as? SidebarReviewChatFilter else {
return
}
let updatedFilter = toggledFilter(filter)
- uiState.sidebarJobFilter = updatedFilter
+ uiState.sidebarReviewChatFilter = updatedFilter
applySelection(updatedFilter)
}
- private func toggledFilter(_ filter: SidebarJobFilter) -> SidebarJobFilter {
+ private func toggledFilter(_ filter: SidebarReviewChatFilter) -> SidebarReviewChatFilter {
guard filter != .all else {
return .all
}
- var currentFilter = uiState.sidebarJobFilter
+ var currentFilter = uiState.sidebarReviewChatFilter
if currentFilter.contains(filter) {
currentFilter.remove(filter)
} else {
@@ -130,11 +130,11 @@ final class ReviewMonitorSidebarJobFilterToolbarItem: NSToolbarItem {
return currentFilter
}
- private func positioningMenuItem(for filter: SidebarJobFilter) -> NSMenuItem? {
+ private func positioningMenuItem(for filter: SidebarReviewChatFilter) -> NSMenuItem? {
if filter.isActive == false {
return filterMenuItems[.all]
}
- for candidate in SidebarJobFilter.menuFilters where filter.contains(candidate) {
+ for candidate in SidebarReviewChatFilter.menuFilters where filter.contains(candidate) {
return filterMenuItems[candidate]
}
return filterMenuItems[.all]
@@ -143,15 +143,15 @@ final class ReviewMonitorSidebarJobFilterToolbarItem: NSToolbarItem {
#if DEBUG
@MainActor
-extension ReviewMonitorSidebarJobFilterToolbarItem {
+extension ReviewMonitorSidebarReviewChatFilterToolbarItem {
var menuItemTitlesForTesting: [String] {
filterMenu.items.map { item in
item.isSeparatorItem ? "-" : item.title
}
}
- var selectedFilterForTesting: SidebarJobFilter {
- uiState.sidebarJobFilter
+ var selectedFilterForTesting: SidebarReviewChatFilter {
+ uiState.sidebarReviewChatFilter
}
var selectedMenuItemTitlesForTesting: [String] {
@@ -164,9 +164,9 @@ extension ReviewMonitorSidebarJobFilterToolbarItem {
toolbarButton.state == .on
}
- func selectFilterForTesting(_ filter: SidebarJobFilter) {
+ func selectFilterForTesting(_ filter: SidebarReviewChatFilter) {
guard let item = filterMenuItems[filter] else {
- fatalError("Sidebar job filter menu item is not configured.")
+ fatalError("Sidebar review chat filter menu item is not configured.")
}
handleFilterSelection(item)
}
diff --git a/Sources/ReviewUI/SidebarPickerSelection.swift b/Sources/ReviewUI/SidebarPickerSelection.swift
new file mode 100644
index 00000000..da565f2a
--- /dev/null
+++ b/Sources/ReviewUI/SidebarPickerSelection.swift
@@ -0,0 +1,24 @@
+import Foundation
+
+enum SidebarPickerSelection: CaseIterable, Hashable {
+ case workspace
+ case account
+
+ var localized: LocalizedStringResource {
+ switch self {
+ case .workspace:
+ "Workspace"
+ case .account:
+ "Account"
+ }
+ }
+
+ var systemImage: String {
+ switch self {
+ case .workspace:
+ "list.bullet"
+ case .account:
+ "person"
+ }
+ }
+}
diff --git a/Sources/ReviewUI/SidebarReviewChatFilter.swift b/Sources/ReviewUI/SidebarReviewChatFilter.swift
new file mode 100644
index 00000000..a8f1254f
--- /dev/null
+++ b/Sources/ReviewUI/SidebarReviewChatFilter.swift
@@ -0,0 +1,76 @@
+import Foundation
+
+package struct SidebarReviewChatFilter: OptionSet, Hashable, Sendable {
+ package let rawValue: Int
+
+ package static let all: SidebarReviewChatFilter = []
+ package static let running = SidebarReviewChatFilter(rawValue: 1 << 0)
+ package static let latestFinished = SidebarReviewChatFilter(rawValue: 1 << 1)
+ package static let menuFilters: [SidebarReviewChatFilter] = [.running, .latestFinished]
+
+ package init(rawValue: Int) {
+ self.rawValue = rawValue
+ }
+
+ package var localized: LocalizedStringResource {
+ if self == .all {
+ "All Items"
+ } else if self == .running {
+ "Running"
+ } else if self == .latestFinished {
+ "Latest Finished"
+ } else {
+ "Custom"
+ }
+ }
+
+ package var isActive: Bool {
+ isEmpty == false
+ }
+
+ package var allowsReviewChatReordering: Bool {
+ self == .all || contains(.running)
+ }
+
+ package var persistedValue: String {
+ guard isActive else {
+ return "all"
+ }
+ return Self.menuFilters.compactMap { filter in
+ contains(filter) ? filter.persistedSingleValue : nil
+ }.joined(separator: ",")
+ }
+
+ package init?(persistedValue: String) {
+ if persistedValue == "all" {
+ self = .all
+ return
+ }
+
+ var filters = SidebarReviewChatFilter(rawValue: 0)
+ for component in persistedValue.split(separator: ",") {
+ switch component.trimmingCharacters(in: .whitespacesAndNewlines) {
+ case "running":
+ filters.insert(.running)
+ case "latestFinished":
+ filters.insert(.latestFinished)
+ default:
+ return nil
+ }
+ }
+ guard filters.isActive else {
+ return nil
+ }
+ self = filters
+ }
+
+ private var persistedSingleValue: String? {
+ if self == .running {
+ return "running"
+ }
+ if self == .latestFinished {
+ return "latestFinished"
+ }
+ return nil
+ }
+}
diff --git a/Sources/ReviewUI/SignIn/ReviewMonitorSignInViewController.swift b/Sources/ReviewUI/SignIn/ReviewMonitorSignInViewController.swift
index b777602f..47aea02a 100644
--- a/Sources/ReviewUI/SignIn/ReviewMonitorSignInViewController.swift
+++ b/Sources/ReviewUI/SignIn/ReviewMonitorSignInViewController.swift
@@ -1,6 +1,6 @@
import AppKit
import Combine
-import CodexReview
+import CodexReviewKit
import SwiftUI
@MainActor
diff --git a/Sources/ReviewUI/SignIn/SignInView.swift b/Sources/ReviewUI/SignIn/SignInView.swift
index 0e60bbaa..caa2d2b8 100644
--- a/Sources/ReviewUI/SignIn/SignInView.swift
+++ b/Sources/ReviewUI/SignIn/SignInView.swift
@@ -1,5 +1,5 @@
import SwiftUI
-import CodexReview
+import CodexReviewKit
struct SignInView: View {
let store: CodexReviewStore
@@ -57,33 +57,3 @@ struct SignInView: View {
return trimmedMessage.isEmpty ? nil : trimmedMessage
}
}
-
-#if DEBUG
-#Preview("Signed Out") {
- SignInView(store: makeSignInPreviewStore())
-}
-
-#Preview("Authenticating") {
- SignInView(store: makeAuthenticatingSignInPreviewStore())
-}
-
-@MainActor
-func makeSignInPreviewStore() -> CodexReviewStore {
- ReviewMonitorPreviewContent.makeStore()
-}
-
-@MainActor
-func makeAuthenticatingSignInPreviewStore() -> CodexReviewStore {
- let store = makeSignInPreviewStore()
- store.auth.updatePhase(
- .signingIn(
- .init(
- title: "Sign in with ChatGPT",
- detail: "Open the browser to continue.",
- browserURL: "https://auth.openai.com/oauth/authorize"
- )
- )
- )
- return store
-}
-#endif
diff --git a/Sources/ReviewUI/Status/AccountRateLimitGaugesView.swift b/Sources/ReviewUI/Status/AccountRateLimitGaugesView.swift
index 122e3645..d051fe97 100644
--- a/Sources/ReviewUI/Status/AccountRateLimitGaugesView.swift
+++ b/Sources/ReviewUI/Status/AccountRateLimitGaugesView.swift
@@ -1,25 +1,25 @@
import SwiftUI
-import CodexReview
+import CodexReviewKit
struct AccountRateLimitGaugesView: View {
- var account: CodexAccount?
+ var account: CodexReviewAccount?
- private static let placeholderRateLimits: [CodexAccount.RateLimitWindow] = [
- CodexAccount.RateLimitWindow(
+ private static let placeholderRateLimits: [CodexReviewAccount.RateLimitWindow] = [
+ CodexReviewAccount.RateLimitWindow(
windowDurationMinutes: 1,
usedPercent: 0
),
- CodexAccount.RateLimitWindow(
+ CodexReviewAccount.RateLimitWindow(
windowDurationMinutes: 2,
usedPercent: 0
),
]
- private var rateLimits: [CodexAccount.RateLimitWindow] {
+ private var rateLimits: [CodexReviewAccount.RateLimitWindow] {
account?.rateLimits ?? []
}
- private var displayedRateLimits: [CodexAccount.RateLimitWindow] {
+ private var displayedRateLimits: [CodexReviewAccount.RateLimitWindow] {
rateLimits.isEmpty ? Self.placeholderRateLimits : rateLimits
}
@@ -44,7 +44,7 @@ struct AccountRateLimitGaugesView: View {
}
private struct RateLimitWindowGaugeView: View {
- var window: CodexAccount.RateLimitWindow
+ var window: CodexReviewAccount.RateLimitWindow
var body: some View {
let remainingPercent = window.remainingPercent
@@ -70,7 +70,7 @@ private struct RateLimitWindowGaugeView: View {
}
}
-extension CodexAccount.RateLimitWindow {
+extension CodexReviewAccount.RateLimitWindow {
var limitResetDate: Date? {
guard usedPercent >= 100 else {
return nil
@@ -101,8 +101,8 @@ extension CodexAccount.RateLimitWindow {
}
@MainActor
-private func makeAccountRateLimitGaugesPreviewAccount() -> CodexAccount {
- let account = CodexAccount(email: "review@example.com", planType: "pro")
+private func makeAccountRateLimitGaugesPreviewAccount() -> CodexReviewAccount {
+ let account = CodexReviewAccount(email: "review@example.com", planType: "pro")
account.updateRateLimits(
[
(
diff --git a/Sources/ReviewUI/Status/ReviewMonitorServerStatusAccessoryViewController.swift b/Sources/ReviewUI/Status/ReviewMonitorServerStatusAccessoryViewController.swift
index 47d564c3..d3541d74 100644
--- a/Sources/ReviewUI/Status/ReviewMonitorServerStatusAccessoryViewController.swift
+++ b/Sources/ReviewUI/Status/ReviewMonitorServerStatusAccessoryViewController.swift
@@ -1,6 +1,6 @@
import AppKit
import SwiftUI
-import CodexReview
+import CodexReviewKit
import ObservationBridge
@MainActor
@@ -80,7 +80,7 @@ final class ReviewMonitorServerStatusAccessoryViewController: NSSplitViewItemAcc
}
struct AccountRateLimitsSectionView: View {
- let account: CodexAccount?
+ let account: CodexReviewAccount?
var body: some View {
ForEach(rateLimits) { window in
@@ -94,7 +94,7 @@ struct AccountRateLimitsSectionView: View {
@ViewBuilder
private func rateLimitsRow(
- _ window: CodexAccount.RateLimitWindow
+ _ window: CodexReviewAccount.RateLimitWindow
) -> some View {
if let details = Self.rateLimitDetailsText(for: window) {
Button {
@@ -104,12 +104,12 @@ struct AccountRateLimitsSectionView: View {
}
}
- private var rateLimits: [CodexAccount.RateLimitWindow] {
+ private var rateLimits: [CodexReviewAccount.RateLimitWindow] {
account?.rateLimits ?? []
}
static func rateLimitDetailsText(
- for window: CodexAccount.RateLimitWindow
+ for window: CodexReviewAccount.RateLimitWindow
) -> AttributedString? {
guard let resetsAt = window.resetsAt else {
return nil
@@ -268,42 +268,3 @@ struct StatusView: View {
}
}
}
-
-#if DEBUG
-
-#Preview("Signed In") {
- let store = makeStatusPreviewStore()
- StatusView(store: store)
- .padding()
-}
-
-#Preview("Server Failed") {
- let store = makeStatusPreviewStore(
- serverState: .failed("The embedded server stopped responding.")
- )
- StatusView(store: store)
- .padding()
-}
-
-@MainActor
-func makeStatusPreviewStore(
- authPhase: CodexReviewAuthModel.Phase = .signedOut,
- account: CodexAccount? = nil,
- serverState: CodexReviewServerState = .running
-) -> CodexReviewStore {
- let store = ReviewMonitorPreviewContent.makeStore()
- let runningServerURL = store.serverURL
- let previewAccounts = ReviewMonitorPreviewContent.makePreviewAccounts()
- let resolvedAccount = account ?? previewAccounts.first
- store.auth.updatePhase(authPhase)
- store.auth.applyPersistedAccountStates(previewAccounts.map(savedAccountPayload(from:)))
- store.auth.selectPersistedAccount(resolvedAccount?.id)
- store.serverState = serverState
- store.serverURL = serverState == .running ? runningServerURL : nil
- return store
-}
-@MainActor
-func makeStatusPreviewAccount() -> CodexAccount {
- ReviewMonitorPreviewContent.makePreviewAccount()
-}
-#endif
diff --git a/Sources/ReviewUI/ReviewMonitorContentPreview.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorContentPreview.swift
similarity index 73%
rename from Sources/ReviewUI/ReviewMonitorContentPreview.swift
rename to Sources/ReviewUIPreviewSupport/ReviewMonitorContentPreview.swift
index 3df88225..6f5a7881 100644
--- a/Sources/ReviewUI/ReviewMonitorContentPreview.swift
+++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorContentPreview.swift
@@ -1,6 +1,7 @@
#if DEBUG
import AppKit
-import CodexReview
+import CodexReviewKit
+import ReviewUI
import SwiftUI
#Preview("Normal") {
@@ -26,27 +27,29 @@ private struct ReviewMonitorContentPreviewHost: NSViewControllerRepresentable {
var previewScenario: PreviewScenario = .normal
var authPhase: CodexReviewAuthModel.Phase = .signedOut
- var account: CodexAccount?
+ var account: CodexReviewAccount?
var serverState: CodexReviewServerState = .running
- func makeNSViewController(context: Context) -> ReviewMonitorRootViewController {
- makeReviewMonitorPreviewContentViewControllerForPreview(
+ func makeNSViewController(context: Context) -> NSViewController {
+ let viewController = makeReviewMonitorPreviewContentViewControllerForPreview(
authPhase: authPhase,
account: account,
serverState: serverState,
- previewStore: previewStore()
+ previewContent: previewContent()
)
+ viewController.prepareForSwiftUIPreviewRendering()
+ return viewController
}
func updateNSViewController(
- _ nsViewController: ReviewMonitorRootViewController,
+ _ nsViewController: NSViewController,
context: Context
) {
}
func sizeThatFits(
_ proposal: ProposedViewSize,
- nsViewController: ReviewMonitorRootViewController,
+ nsViewController: NSViewController,
context: Context
) -> CGSize? {
guard
@@ -60,7 +63,7 @@ private struct ReviewMonitorContentPreviewHost: NSViewControllerRepresentable {
return CGSize(width: width, height: height)
}
- private func previewStore() -> CodexReviewStore? {
+ private func previewContent() -> ReviewMonitorPreviewContentSource? {
guard case .running = serverState else {
return nil
}
@@ -68,7 +71,7 @@ private struct ReviewMonitorContentPreviewHost: NSViewControllerRepresentable {
case .normal:
return nil
case .commandOutput:
- return ReviewMonitorPreviewContent.makeCommandOutputStore()
+ return ReviewMonitorPreviewContent.makeCommandOutputContentSource()
}
}
}
diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift
new file mode 100644
index 00000000..51572de7
--- /dev/null
+++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift
@@ -0,0 +1,1022 @@
+import CodexKit
+import CodexAppServerKitTesting
+import CodexReviewKit
+import Foundation
+import ReviewUI
+
+private actor ReviewMonitorPreviewSnapshotMutationQueue {
+ private var tailTask: Task?
+
+ func run(
+ _ operation: @Sendable @escaping () async -> Value
+ ) async -> Value {
+ let previousTask = tailTask
+ let task = Task {
+ await previousTask?.value
+ return await operation()
+ }
+ tailTask = Task {
+ _ = await task.value
+ }
+ return await task.value
+ }
+}
+
+private actor ReviewMonitorPreviewArchivedChatIDs {
+ private var ids: Set = []
+
+ func insert(_ id: CodexThreadID) {
+ ids.insert(id)
+ }
+
+ func contains(_ id: CodexThreadID) -> Bool {
+ ids.contains(id)
+ }
+}
+
+private actor ReviewMonitorPreviewCancelledChatIDs {
+ private var ids: Set = []
+
+ func insert(_ id: CodexThreadID) {
+ ids.insert(id)
+ }
+
+ func contains(_ id: CodexThreadID) -> Bool {
+ ids.contains(id)
+ }
+}
+
+private struct ReviewMonitorPreviewStoredThreadItem: Sendable {
+ var item: CodexThreadItem
+ var turnID: CodexTurnID
+}
+
+@MainActor
+struct ReviewMonitorPreviewChatLogFixture {
+ let chatID: CodexThreadID
+ let title: String
+ let preview: String?
+ let model: String?
+ let workspaceCWD: String?
+ let updatedAt: Date?
+ let recencyAt: Date?
+ let status: CodexThreadStatus?
+ let cwd: String
+ let streamID: String
+ let isRunning: Bool
+ let initialThreadSnapshot: CodexThreadSnapshot
+
+ init(
+ chatID: CodexThreadID,
+ title: String,
+ preview: String?,
+ model: String?,
+ workspaceCWD: String?,
+ updatedAt: Date?,
+ recencyAt: Date?,
+ status: CodexThreadStatus?,
+ cwd: String,
+ streamID: String,
+ isRunning: Bool,
+ initialThreadSnapshot: CodexThreadSnapshot
+ ) {
+ self.chatID = chatID
+ self.title = title
+ self.preview = preview
+ self.model = model
+ self.workspaceCWD = workspaceCWD
+ self.updatedAt = updatedAt
+ self.recencyAt = recencyAt
+ self.status = status
+ self.cwd = cwd
+ self.streamID = streamID
+ self.isRunning = isRunning
+ self.initialThreadSnapshot = initialThreadSnapshot
+ }
+}
+
+@MainActor
+final class ReviewMonitorPreviewAppServerRuntime {
+ let modelSource = ReviewMonitorCodexModelSource()
+
+ private let fixtures: [ReviewMonitorPreviewChatLogFixture]
+ private let fixturesByChatID: [CodexThreadID: ReviewMonitorPreviewChatLogFixture]
+ private var threadStore: CodexAppServerTestThreadStore
+ private var runtime: CodexAppServerTestRuntime?
+ private var container: CodexModelContainer?
+ private var startTask: Task?
+ private var streamTask: Task?
+ private var notificationTask: Task?
+ private let snapshotMutationQueue = ReviewMonitorPreviewSnapshotMutationQueue()
+ private let archivedChatIDs = ReviewMonitorPreviewArchivedChatIDs()
+ private let cancelledChatIDs = ReviewMonitorPreviewCancelledChatIDs()
+ private var tick = 0
+
+ init(fixtures: [ReviewMonitorPreviewChatLogFixture]) {
+ self.fixtures = fixtures
+ self.fixturesByChatID = Dictionary(uniqueKeysWithValues: fixtures.map { ($0.chatID, $0) })
+ self.threadStore = CodexAppServerTestThreadStore(
+ threads: fixtures.map(\.threadSnapshot)
+ )
+ }
+
+ var initialChatID: CodexThreadID? {
+ fixtures.first?.chatID
+ }
+
+ func chatPresentation(id: CodexThreadID) -> (title: String, subtitle: String)? {
+ guard let fixture = fixturesByChatID[id] else {
+ return nil
+ }
+ return (title: fixture.title, subtitle: fixture.cwd)
+ }
+
+ func snapshotForTesting(chatID: CodexThreadID) async -> CodexThreadSnapshot? {
+ await threadStore.snapshot(id: chatID)
+ }
+
+ func interruptRequestCountForTesting() async -> Int {
+ guard let runtime else {
+ return 0
+ }
+ return await runtime.transport.recordedRequests(method: "turn/interrupt").count
+ }
+
+ func archiveRequestCountForTesting() async -> Int {
+ guard let runtime else {
+ return 0
+ }
+ return await runtime.transport.recordedRequests(method: "thread/archive").count
+ }
+
+ func upsertPreviewItem(
+ id: String,
+ kind: CodexThreadItem.Kind,
+ content: CodexThreadItem.Content,
+ to chatID: CodexThreadID
+ ) async {
+ guard let fixture = fixturesByChatID[chatID] else {
+ return
+ }
+ guard let item = await upsertStoredItem(
+ id: id,
+ kind: kind,
+ content: content,
+ in: fixture
+ ) else {
+ return
+ }
+ start()
+ enqueueNotification { [weak self] in
+ await self?.emitItem(item, for: fixture)
+ }
+ }
+
+ func appendPreviewText(
+ _ delta: String,
+ to chatID: CodexThreadID,
+ itemID: String,
+ kind: CodexThreadItem.Kind,
+ content: CodexThreadItem.Content
+ ) async {
+ guard delta.isEmpty == false,
+ fixturesByChatID[chatID] != nil else {
+ return
+ }
+ guard let item = await appendStoredText(
+ delta,
+ itemID: itemID,
+ kind: kind,
+ content: content,
+ in: chatID
+ ) else {
+ return
+ }
+ start()
+ enqueueNotification { [weak self] in
+ do {
+ try await self?.ensureStarted()
+ guard let runtime = self?.runtime else {
+ return
+ }
+ try await self?.emitTextDelta(
+ delta,
+ itemID: itemID,
+ turnID: item.turnID,
+ chatID: chatID,
+ kind: kind,
+ content: item.item.content,
+ runtime: runtime
+ )
+ } catch {
+ }
+ }
+ }
+
+ private func enqueueNotification(_ operation: @escaping @MainActor () async -> Void) {
+ let previousTask = notificationTask
+ notificationTask = Task { @MainActor in
+ await previousTask?.value
+ await operation()
+ }
+ }
+
+ func start() {
+ guard startTask == nil, runtime == nil else {
+ return
+ }
+ startTask = Task { @MainActor [weak self] in
+ do {
+ try await self?.startNow()
+ } catch {
+ }
+ }
+ }
+
+ func startStreaming(interval: Duration) {
+ start()
+ guard streamTask == nil else {
+ return
+ }
+ streamTask = Task { @MainActor [weak self] in
+ while Task.isCancelled == false {
+ try? await Task.sleep(for: interval)
+ guard let self, Task.isCancelled == false else {
+ return
+ }
+ _ = await self.appendPreviewStreamTick(
+ after: self.tick,
+ emitsNotifications: true
+ )
+ }
+ }
+ }
+
+ @discardableResult
+ func appendPreviewStreamTick(
+ after currentTick: Int = 0,
+ emitsNotifications: Bool = false
+ ) async -> Int {
+ var runningFixtures: [ReviewMonitorPreviewChatLogFixture] = []
+ for fixture in fixtures where fixture.isRunning {
+ if await cancelledChatIDs.contains(fixture.chatID) == false {
+ runningFixtures.append(fixture)
+ }
+ }
+ guard runningFixtures.isEmpty == false else {
+ return currentTick
+ }
+
+ if emitsNotifications {
+ do {
+ try await ensureStarted()
+ } catch {
+ return currentTick
+ }
+ }
+
+ let nextTick = currentTick + 1
+ for (index, fixture) in runningFixtures.enumerated() {
+ guard let frame = ReviewMonitorPreviewContent.streamFrame(
+ forRunningChatAt: index,
+ tick: nextTick
+ ) else {
+ continue
+ }
+ guard let storedItem = await apply(frame.step, cycle: frame.cycle, for: fixture) else {
+ continue
+ }
+ if emitsNotifications {
+ enqueueNotification { [weak self] in
+ await self?.emit(frame.step, storedItem: storedItem, for: fixture)
+ }
+ }
+ }
+ tick = nextTick
+ return nextTick
+ }
+
+ private func ensureStarted() async throws {
+ if runtime != nil {
+ return
+ }
+ if let startTask {
+ await startTask.value
+ return
+ }
+ try await startNow()
+ }
+
+ private func startNow() async throws {
+ if runtime != nil {
+ return
+ }
+ let runtime = try await CodexAppServerTestRuntime.start(threadStore: threadStore)
+ let container = CodexModelContainer(appServer: runtime.server)
+ self.runtime = runtime
+ try await rebindRuntimeToCurrentThreadStore(runtime)
+ await runtime.transport.handle(method: "turn/interrupt") { params in
+ let request = try JSONDecoder().decode(PreviewTurnInterruptParams.self, from: params)
+ let threadID = CodexThreadID(rawValue: request.threadID)
+ await self.cancelPreviewChat(threadID)
+ return Data("{}".utf8)
+ }
+ self.container = container
+ modelSource.install(container: container)
+ }
+
+ private func rebindRuntimeToCurrentThreadStore(_ runtime: CodexAppServerTestRuntime) async throws {
+ var reboundStore: CodexAppServerTestThreadStore
+ repeat {
+ reboundStore = threadStore
+ try await runtime.transport.stubThreads(reboundStore)
+ let archivedChatIDs = archivedChatIDs
+ let store = reboundStore
+ await runtime.transport.handle(method: "thread/archive") { params in
+ let request = try JSONDecoder().decode(PreviewThreadArchiveParams.self, from: params)
+ let threadID = CodexThreadID(rawValue: request.threadID)
+ await archivedChatIDs.insert(threadID)
+ await store.remove(id: threadID)
+ return Data("{}".utf8)
+ }
+ } while reboundStore !== threadStore
+ }
+
+ private func cancelPreviewChat(_ chatID: CodexThreadID) async {
+ guard let fixture = fixturesByChatID[chatID] else {
+ return
+ }
+ await cancelledChatIDs.insert(chatID)
+ guard let cancelledSnapshot = await updateStoredSnapshot(for: fixture, mutation: { snapshot in
+ snapshot.turns = snapshot.turns?.map { turn in
+ var turn = turn
+ if turn.status.isTerminalForPreview == false {
+ turn.status = .cancelled
+ }
+ return turn
+ }
+ }) else {
+ return
+ }
+ enqueueNotification { [weak self] in
+ await self?.emitCancelledState(cancelledSnapshot, for: fixture)
+ }
+ }
+
+ private func emit(
+ _ step: ReviewMonitorPreviewContent.PreviewChatLogStreamStep,
+ storedItem: ReviewMonitorPreviewStoredThreadItem,
+ for fixture: ReviewMonitorPreviewChatLogFixture
+ ) async {
+ guard let runtime else {
+ return
+ }
+ do {
+ switch step.mode {
+ case .textDelta:
+ try await emitTextDelta(
+ step.deltaText ?? "",
+ itemID: storedItem.item.id,
+ turnID: storedItem.turnID,
+ chatID: fixture.chatID,
+ kind: storedItem.item.kind,
+ content: storedItem.item.content,
+ runtime: runtime
+ )
+ case .update, .complete:
+ await emitItem(storedItem, for: fixture)
+ }
+ } catch {
+ }
+ }
+
+ private func apply(
+ _ step: ReviewMonitorPreviewContent.PreviewChatLogStreamStep,
+ cycle: Int,
+ for fixture: ReviewMonitorPreviewChatLogFixture
+ ) async -> ReviewMonitorPreviewStoredThreadItem? {
+ let itemID = ReviewMonitorPreviewContent.previewChatLogItemID(
+ itemName: step.itemName,
+ streamID: fixture.streamID,
+ cycle: cycle
+ )
+ switch step.mode {
+ case .update, .complete:
+ return await upsertStoredItem(
+ id: itemID,
+ kind: step.kind,
+ content: step.content,
+ in: fixture
+ )
+ case .textDelta:
+ return await appendStoredText(
+ step.deltaText ?? "",
+ itemID: itemID,
+ kind: step.kind,
+ content: step.content,
+ in: fixture.chatID
+ )
+ }
+ }
+
+ private func upsertStoredItem(
+ id: String,
+ kind: CodexThreadItem.Kind,
+ content: CodexThreadItem.Content,
+ in fixture: ReviewMonitorPreviewChatLogFixture
+ ) async -> ReviewMonitorPreviewStoredThreadItem? {
+ let fallbackTurnID = fixture.previewFallbackTurnID
+ return await updateStoredSnapshot(for: fixture) { snapshot in
+ let turnID = snapshot.ensurePreviewTurn(fallback: fallbackTurnID)
+ let item = CodexThreadItem(
+ id: id,
+ kind: kind,
+ content: content
+ )
+ guard let turnIndex = snapshot.turns?.lastIndex(where: { $0.id == turnID }) else {
+ return nil
+ }
+ if let itemIndex = snapshot.turns?[turnIndex].items.firstIndex(where: { $0.id == item.id }) {
+ snapshot.turns?[turnIndex].items[itemIndex] = item
+ } else {
+ snapshot.turns?[turnIndex].items.append(item)
+ }
+ return ReviewMonitorPreviewStoredThreadItem(item: item, turnID: turnID)
+ }
+ }
+
+ private func appendStoredText(
+ _ delta: String,
+ itemID: String,
+ kind: CodexThreadItem.Kind,
+ content: CodexThreadItem.Content,
+ in chatID: CodexThreadID
+ ) async -> ReviewMonitorPreviewStoredThreadItem? {
+ guard delta.isEmpty == false,
+ let fixture = fixturesByChatID[chatID] else {
+ return nil
+ }
+ let fallbackTurnID = fixture.previewFallbackTurnID
+ return await updateStoredSnapshot(for: fixture) { snapshot in
+ let turnID = snapshot.ensurePreviewTurn(fallback: fallbackTurnID)
+ guard let turnIndex = snapshot.turns?.lastIndex(where: { $0.id == turnID }) else {
+ return nil
+ }
+ if let itemIndex = snapshot.turns?[turnIndex].items.firstIndex(where: { $0.id == itemID }) {
+ snapshot.turns?[turnIndex].items[itemIndex].content.appendPreviewText(delta)
+ guard let item = snapshot.turns?[turnIndex].items[itemIndex] else {
+ return nil
+ }
+ return ReviewMonitorPreviewStoredThreadItem(item: item, turnID: turnID)
+ }
+ var item = CodexThreadItem(
+ id: itemID,
+ kind: kind,
+ content: content
+ )
+ item.content.appendPreviewText(delta)
+ snapshot.turns?[turnIndex].items.append(item)
+ return ReviewMonitorPreviewStoredThreadItem(item: item, turnID: turnID)
+ }
+ }
+
+ private func updateStoredSnapshot(
+ for fixture: ReviewMonitorPreviewChatLogFixture,
+ _ mutation: @escaping @Sendable (inout CodexThreadSnapshot) -> ReviewMonitorPreviewStoredThreadItem?
+ ) async -> ReviewMonitorPreviewStoredThreadItem? {
+ await snapshotMutationQueue.run { @MainActor [weak self] in
+ guard let self,
+ var snapshot = await self.threadStore.snapshot(id: fixture.chatID),
+ let item = mutation(&snapshot) else {
+ return nil
+ }
+ await self.replaceThreadStorePreservingFixtureOrder(
+ with: fixture.threadSnapshot(snapshot)
+ )
+ return item
+ }
+ }
+
+ private func updateStoredSnapshot(
+ for fixture: ReviewMonitorPreviewChatLogFixture,
+ mutation: @escaping @Sendable (inout CodexThreadSnapshot) -> Void
+ ) async -> CodexThreadSnapshot? {
+ await snapshotMutationQueue.run { @MainActor [weak self] in
+ guard let self,
+ var snapshot = await self.threadStore.snapshot(id: fixture.chatID) else {
+ return nil
+ }
+ mutation(&snapshot)
+ await self.replaceThreadStorePreservingFixtureOrder(
+ with: fixture.threadSnapshot(snapshot, status: .idle)
+ )
+ return snapshot
+ }
+ }
+
+ private func replaceThreadStorePreservingFixtureOrder(
+ with updatedSnapshot: CodexThreadSnapshot
+ ) async {
+ let currentStore = threadStore
+ let storedSnapshots = await currentStore.snapshots()
+ let fixtureSnapshotIDs = Set(fixtures.map(\.chatID))
+ var orderedFixtureSnapshots: [CodexThreadSnapshot] = []
+ for fixture in fixtures {
+ if await archivedChatIDs.contains(fixture.chatID) {
+ continue
+ }
+ if fixture.chatID == updatedSnapshot.id {
+ orderedFixtureSnapshots.append(updatedSnapshot)
+ continue
+ }
+ orderedFixtureSnapshots.append(
+ await currentStore.snapshot(id: fixture.chatID) ?? fixture.threadSnapshot
+ )
+ }
+ let nonFixtureSnapshots = storedSnapshots.filter { snapshot in
+ fixtureSnapshotIDs.contains(snapshot.id) == false
+ }
+ let replacementStore = CodexAppServerTestThreadStore(
+ threads: orderedFixtureSnapshots + nonFixtureSnapshots
+ )
+ threadStore = replacementStore
+ do {
+ if let runtime {
+ try await rebindRuntimeToCurrentThreadStore(runtime)
+ }
+ } catch {
+ }
+ }
+
+ private func emitItem(
+ _ storedItem: ReviewMonitorPreviewStoredThreadItem,
+ for fixture: ReviewMonitorPreviewChatLogFixture
+ ) async {
+ do {
+ try await ensureStarted()
+ guard let runtime else {
+ return
+ }
+ await runtime.transport.waitForNotificationStreamCount(1)
+ await runtime.transport.waitForRequest(method: "thread/read")
+ try await runtime.transport.emitServerNotification(
+ method: "item/updated",
+ params: PreviewThreadItemParams(
+ threadID: fixture.chatID.rawValue,
+ turnID: storedItem.turnID.rawValue,
+ item: makePreviewNotificationItem(
+ id: storedItem.item.id,
+ kind: storedItem.item.kind,
+ content: storedItem.item.content
+ )
+ )
+ )
+ } catch {
+ }
+ }
+
+ private func emitTextDelta(
+ _ delta: String,
+ itemID: String,
+ turnID: CodexTurnID,
+ chatID: CodexThreadID,
+ kind: CodexThreadItem.Kind,
+ content: CodexThreadItem.Content,
+ runtime: CodexAppServerTestRuntime
+ ) async throws {
+ guard delta.isEmpty == false else {
+ return
+ }
+ await runtime.transport.waitForNotificationStreamCount(1)
+ await runtime.transport.waitForRequest(method: "thread/read")
+ if isReasoningDelta(kind: kind, content: content) {
+ let method: String
+ let summaryIndex: Int?
+ let contentIndex: Int?
+ if case .reasoning(let reasoning) = content,
+ reasoning.summary.isEmpty == false {
+ method = "item/reasoning/summaryTextDelta"
+ summaryIndex = 0
+ contentIndex = nil
+ } else {
+ method = "item/reasoning/textDelta"
+ summaryIndex = nil
+ contentIndex = 0
+ }
+ try await runtime.transport.emitServerNotification(
+ method: method,
+ params: PreviewTurnDeltaParams(
+ threadID: chatID.rawValue,
+ turnID: turnID.rawValue,
+ itemID: itemID,
+ delta: delta,
+ phase: nil,
+ summaryIndex: summaryIndex,
+ contentIndex: contentIndex
+ )
+ )
+ return
+ }
+ switch kind {
+ case .commandExecution:
+ try await emitOutputDelta(
+ method: "item/commandExecution/outputDelta",
+ delta: delta,
+ itemID: itemID,
+ turnID: turnID,
+ chatID: chatID,
+ runtime: runtime
+ )
+ case .fileChange:
+ try await emitOutputDelta(
+ method: "item/fileChange/outputDelta",
+ delta: delta,
+ itemID: itemID,
+ turnID: turnID,
+ chatID: chatID,
+ runtime: runtime
+ )
+ case .mcpToolCall, .dynamicToolCall, .collabAgentToolCall, .subAgentActivity:
+ try await emitOutputDelta(
+ method: "item/mcpToolCall/progress",
+ delta: delta,
+ itemID: itemID,
+ turnID: turnID,
+ chatID: chatID,
+ runtime: runtime
+ )
+ default:
+ try await runtime.transport.emitServerNotification(
+ method: "item/agentMessage/delta",
+ params: PreviewTurnDeltaParams(
+ threadID: chatID.rawValue,
+ turnID: turnID.rawValue,
+ itemID: itemID,
+ delta: delta,
+ phase: "final_answer",
+ summaryIndex: nil,
+ contentIndex: nil
+ )
+ )
+ }
+ }
+
+ private func isReasoningDelta(
+ kind: CodexThreadItem.Kind,
+ content: CodexThreadItem.Content
+ ) -> Bool {
+ if kind == .reasoning {
+ return true
+ }
+ if case .reasoning = content {
+ return true
+ }
+ return false
+ }
+
+ private func emitOutputDelta(
+ method: String,
+ delta: String,
+ itemID: String,
+ turnID: CodexTurnID,
+ chatID: CodexThreadID,
+ runtime: CodexAppServerTestRuntime
+ ) async throws {
+ try await runtime.transport.emitServerNotification(
+ method: method,
+ params: PreviewTurnDeltaParams(
+ threadID: chatID.rawValue,
+ turnID: turnID.rawValue,
+ itemID: itemID,
+ delta: delta,
+ phase: nil,
+ summaryIndex: nil,
+ contentIndex: nil
+ )
+ )
+ }
+
+ private func emitCancelledState(
+ _ snapshot: CodexThreadSnapshot,
+ for fixture: ReviewMonitorPreviewChatLogFixture
+ ) async {
+ do {
+ try await ensureStarted()
+ guard let runtime else {
+ return
+ }
+ await runtime.transport.waitForNotificationStreamCount(1)
+ let turnID = snapshot.turns?.last?.id ?? fixture.previewFallbackTurnID
+ try await runtime.transport.emitServerNotification(
+ method: "thread/status/changed",
+ params: PreviewThreadStatusParams(
+ threadID: fixture.chatID.rawValue,
+ status: .init(type: "idle")
+ )
+ )
+ try await runtime.transport.emitServerNotification(
+ method: "turn/completed",
+ params: PreviewTurnCompletedParams(
+ threadID: fixture.chatID.rawValue,
+ turn: .init(
+ id: turnID.rawValue,
+ status: "cancelled",
+ completedAt: Int(Date().timeIntervalSince1970)
+ )
+ )
+ )
+ } catch {
+ }
+ }
+}
+
+private struct PreviewTurnInterruptParams: Decodable, Sendable {
+ var threadID: String
+
+ enum CodingKeys: String, CodingKey {
+ case threadID = "threadId"
+ }
+}
+
+private struct PreviewThreadItemParams: 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 id: String
+ var type: String
+ var text: String?
+ var phase: String?
+ var command: String?
+ var cwd: String?
+ var output: String?
+ var exitCode: Int?
+ var status: String?
+ var path: String?
+ }
+}
+
+private struct PreviewThreadArchiveParams: Decodable, Sendable {
+ var threadID: String
+
+ enum CodingKeys: String, CodingKey {
+ case threadID = "threadId"
+ }
+}
+
+private struct PreviewThreadStatusParams: Encodable, Sendable {
+ var threadID: String
+ var status: Status
+
+ enum CodingKeys: String, CodingKey {
+ case threadID = "threadId"
+ case status
+ }
+
+ struct Status: Encodable, Sendable {
+ var type: String
+ }
+}
+
+private struct PreviewTurnCompletedParams: Encodable, Sendable {
+ var threadID: String
+ var turn: Turn
+
+ enum CodingKeys: String, CodingKey {
+ case threadID = "threadId"
+ case turn
+ }
+
+ struct Turn: Encodable, Sendable {
+ var id: String
+ var status: String?
+ var completedAt: Int?
+ }
+}
+
+private extension Optional where Wrapped == CodexTurnStatus {
+ var isTerminalForPreview: Bool {
+ switch self {
+ case .completed?, .failed?, .interrupted?, .cancelled?:
+ true
+ case .running?, .unknown?, nil:
+ false
+ }
+ }
+}
+
+private struct PreviewTurnDeltaParams: Encodable, Sendable {
+ var threadID: String
+ var turnID: String
+ var itemID: String
+ var delta: String
+ var phase: String?
+ var summaryIndex: Int?
+ var contentIndex: Int?
+
+ enum CodingKeys: String, CodingKey {
+ case threadID = "threadId"
+ case turnID = "turnId"
+ case itemID = "itemId"
+ case delta
+ case phase
+ case summaryIndex
+ case contentIndex
+ }
+}
+
+private func makePreviewNotificationItem(
+ id: String,
+ kind: CodexThreadItem.Kind,
+ content: CodexThreadItem.Content
+) -> PreviewThreadItemParams.Item {
+ PreviewThreadItemParams.Item(
+ id: id,
+ type: previewNotificationItemType(kind: kind, content: content),
+ text: previewNotificationText(content),
+ phase: previewNotificationPhase(content),
+ command: previewNotificationCommand(content),
+ cwd: previewNotificationCWD(content),
+ output: previewNotificationOutput(content),
+ exitCode: previewNotificationExitCode(content),
+ status: previewNotificationStatus(content),
+ path: previewNotificationPath(content)
+ )
+}
+
+private func previewNotificationItemType(
+ kind: CodexThreadItem.Kind,
+ content: CodexThreadItem.Content
+) -> String {
+ switch content {
+ case .diagnostic:
+ "diagnostic"
+ default:
+ kind.rawValue
+ }
+}
+
+private func previewNotificationText(_ content: CodexThreadItem.Content) -> 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 .toolCall(let toolCall):
+ toolCall.result ?? toolCall.error ?? toolCall.name
+ case .contextCompaction(let text):
+ text
+ case .command, .fileChange, .unknown:
+ nil
+ }
+}
+
+private func previewNotificationPhase(_ content: CodexThreadItem.Content) -> String? {
+ if case .message(let message) = content {
+ return message.phase?.rawValue
+ }
+ return nil
+}
+
+private func previewNotificationCommand(_ content: CodexThreadItem.Content) -> String? {
+ if case .command(let command) = content {
+ return command.command
+ }
+ return nil
+}
+
+private func previewNotificationCWD(_ content: CodexThreadItem.Content) -> String? {
+ if case .command(let command) = content {
+ return command.cwd
+ }
+ return nil
+}
+
+private func previewNotificationOutput(_ content: CodexThreadItem.Content) -> String? {
+ switch content {
+ case .command(let command):
+ return command.output
+ case .fileChange(let fileChange):
+ return fileChange.output
+ default:
+ return nil
+ }
+}
+
+private func previewNotificationExitCode(_ content: CodexThreadItem.Content) -> Int? {
+ if case .command(let command) = content {
+ return command.exitCode
+ }
+ return nil
+}
+
+private func previewNotificationStatus(_ content: CodexThreadItem.Content) -> String? {
+ switch content {
+ case .command(let command):
+ command.status?.rawValue
+ case .fileChange(let fileChange):
+ fileChange.status?.rawValue
+ case .toolCall(let toolCall):
+ toolCall.status?.rawValue
+ default:
+ nil
+ }
+}
+
+private func previewNotificationPath(_ content: CodexThreadItem.Content) -> String? {
+ if case .fileChange(let fileChange) = content {
+ return fileChange.path
+ }
+ return nil
+}
+
+private extension ReviewMonitorPreviewChatLogFixture {
+ var threadSnapshot: CodexThreadSnapshot {
+ threadSnapshot(initialThreadSnapshot)
+ }
+
+ var previewFallbackTurnID: CodexTurnID {
+ initialThreadSnapshot.turns?.last?.id ?? CodexTurnID(rawValue: "preview-turn")
+ }
+
+ func threadSnapshot(
+ _ snapshot: CodexThreadSnapshot,
+ status: CodexThreadStatus? = nil
+ ) -> CodexThreadSnapshot {
+ CodexThreadSnapshot(
+ id: chatID,
+ workspace: workspaceCWD.map { URL(fileURLWithPath: $0, isDirectory: true) },
+ name: title,
+ preview: preview,
+ modelProvider: model,
+ updatedAt: updatedAt,
+ recencyAt: recencyAt,
+ status: status ?? self.status,
+ turns: snapshot.turns
+ )
+ }
+}
+
+private extension CodexThreadSnapshot {
+ mutating func ensurePreviewTurn(fallback turnID: CodexTurnID) -> CodexTurnID {
+ if let existingTurnID = turns?.last?.id {
+ return existingTurnID
+ }
+ turns = [CodexTurnSnapshot(id: turnID, status: .running)]
+ return turnID
+ }
+}
+
+private extension CodexThreadItem.Content {
+ mutating func appendPreviewText(_ delta: String) {
+ switch self {
+ case .message(var message):
+ message.text += delta
+ self = .message(message)
+ case .plan(let text):
+ self = .plan(text + delta)
+ case .reasoning(var reasoning):
+ if reasoning.summary.isEmpty {
+ append(delta, to: &reasoning.content)
+ } else {
+ append(delta, to: &reasoning.summary)
+ }
+ self = .reasoning(reasoning)
+ case .command(var command):
+ command.output = (command.output ?? "") + delta
+ self = .command(command)
+ case .fileChange(var fileChange):
+ fileChange.output = (fileChange.output ?? "") + delta
+ self = .fileChange(fileChange)
+ case .toolCall(var toolCall):
+ toolCall.result = (toolCall.result ?? "") + delta
+ self = .toolCall(toolCall)
+ case .contextCompaction(let text):
+ self = .contextCompaction((text ?? "") + delta)
+ case .diagnostic(let text):
+ self = .diagnostic(text + delta)
+ case .log(let text):
+ self = .log(text + delta)
+ case .unknown(var rawItem):
+ rawItem.text = (rawItem.text ?? "") + delta
+ self = .unknown(rawItem)
+ }
+ }
+
+ private func append(_ delta: String, to parts: inout [String]) {
+ if parts.isEmpty {
+ parts.append(delta)
+ } else {
+ parts[parts.count - 1] += delta
+ }
+ }
+}
diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift
new file mode 100644
index 00000000..ec7a6cca
--- /dev/null
+++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift
@@ -0,0 +1,136 @@
+import AppKit
+import CodexKit
+import CodexReviewKit
+import Foundation
+import ObjectiveC
+import ReviewUI
+
+nonisolated(unsafe) private var previewContentSourceAssociationKey: UInt8 = 0
+
+@MainActor
+func makeReviewMonitorPreviewContentViewController() -> NSViewController {
+ makeReviewMonitorPreviewContentViewControllerForPreview()
+}
+
+@MainActor
+func makeReviewMonitorPreviewContentViewControllerForPreview(
+ authPhase: CodexReviewAuthModel.Phase = .signedOut,
+ account: CodexReviewAccount? = nil,
+ serverState: CodexReviewServerState = .running,
+ previewContent: ReviewMonitorPreviewContentSource? = nil
+) -> NSViewController {
+ let store: CodexReviewStore
+ let resolvedPreviewContent: ReviewMonitorPreviewContentSource?
+ let ownsPreviewContent = previewContent == nil
+ switch serverState {
+ case .running:
+ if let previewContent {
+ resolvedPreviewContent = previewContent
+ store = previewContent.store
+ } else {
+ let previewContent = ReviewMonitorPreviewContent.makeContentSource()
+ resolvedPreviewContent = previewContent
+ store = previewContent.store
+ }
+ case .failed, .starting, .stopped:
+ resolvedPreviewContent = nil
+ store = CodexReviewStore.makePreviewStore()
+ store.serverState = serverState
+ store.serverURL = nil
+ }
+ let previewAccounts = ReviewMonitorPreviewContent.makePreviewAccounts()
+ let resolvedAccount = account ?? previewAccounts.first
+ store.auth.updatePhase(authPhase)
+ store.auth.applyPersistedAccountStates(previewAccounts.map(savedAccountPayload(from:)))
+ store.auth.selectPersistedAccount(resolvedAccount?.id)
+
+ let uiState = ReviewMonitorUIState(auth: store.auth)
+ uiState.selectChat(id: resolvedPreviewContent?.initialChatID)
+ if let resolvedPreviewContent {
+ if ownsPreviewContent {
+ resolvedPreviewContent.startStreaming(interval: .milliseconds(40))
+ } else {
+ resolvedPreviewContent.start()
+ }
+ }
+ let viewController = ReviewMonitorRootViewController(
+ store: store,
+ uiState: uiState,
+ codexModelSource: resolvedPreviewContent?.codexModelSource,
+ dependencyRetainer: resolvedPreviewContent
+ )
+ viewController.installPreviewContentSourceForTesting(resolvedPreviewContent)
+ return viewController
+}
+
+public extension ReviewMonitorWindowController {
+ convenience init(
+ appStore store: CodexReviewStore,
+ codexModelSource: ReviewMonitorCodexModelSource,
+ showSettings: @escaping @MainActor () -> Void
+ ) {
+ self.init(
+ store: store,
+ codexModelSource: codexModelSource,
+ showSettings: showSettings
+ )
+ }
+
+ convenience init(
+ previewContent: ReviewMonitorPreviewContentSource,
+ showSettings: @escaping @MainActor () -> Void
+ ) {
+ let store = previewContent.store
+ previewContent.startStreaming(interval: .milliseconds(40))
+ let uiState = ReviewMonitorUIState(auth: store.auth)
+ uiState.selectChat(id: previewContent.initialChatID)
+ self.init(
+ store: store,
+ uiState: uiState,
+ codexModelSource: previewContent.codexModelSource,
+ showSettings: showSettings,
+ dependencyRetainer: previewContent
+ )
+ window?.contentViewController?.installPreviewContentSourceForTesting(previewContent)
+ }
+}
+
+public extension NSViewController {
+ func prepareForSwiftUIPreviewRendering() {
+ guard let rootViewController = self as? ReviewMonitorRootViewController else {
+ loadViewIfNeeded()
+ view.layoutSubtreeIfNeeded()
+ return
+ }
+ rootViewController.prepareForImmediateRenderingForPreviewSupport()
+ }
+
+ @discardableResult
+ func appendPreviewChatLogStreamTickForTesting(after tick: Int = 0) async -> Int? {
+ guard let previewContent = previewContentSourceForTesting else {
+ return nil
+ }
+ return await previewContent.appendPreviewChatLogStreamTick(
+ after: tick,
+ emitsNotifications: true
+ )
+ }
+}
+
+private extension NSViewController {
+ var previewContentSourceForTesting: ReviewMonitorPreviewContentSource? {
+ objc_getAssociatedObject(
+ self,
+ &previewContentSourceAssociationKey
+ ) as? ReviewMonitorPreviewContentSource
+ }
+
+ func installPreviewContentSourceForTesting(_ previewContent: ReviewMonitorPreviewContentSource?) {
+ objc_setAssociatedObject(
+ self,
+ &previewContentSourceAssociationKey,
+ previewContent,
+ .OBJC_ASSOCIATION_RETAIN_NONATOMIC
+ )
+ }
+}
diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift
new file mode 100644
index 00000000..b8e3af03
--- /dev/null
+++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift
@@ -0,0 +1,1088 @@
+import Foundation
+import CodexKit
+@_spi(Testing) import CodexReviewKit
+import ReviewUI
+
+@MainActor
+public final class ReviewMonitorPreviewContentSource {
+ public let store: CodexReviewStore
+ let runtime: ReviewMonitorPreviewAppServerRuntime
+
+ public var codexModelSource: ReviewMonitorCodexModelSource {
+ runtime.modelSource
+ }
+
+ init(
+ store: CodexReviewStore,
+ runtime: ReviewMonitorPreviewAppServerRuntime
+ ) {
+ self.store = store
+ self.runtime = runtime
+ }
+
+ var initialChatID: CodexThreadID? {
+ runtime.initialChatID
+ }
+
+ func start() {
+ runtime.start()
+ }
+
+ func startStreaming(interval: Duration) {
+ runtime.startStreaming(interval: interval)
+ }
+
+ @discardableResult
+ public func appendPreviewChatLogStreamTick(
+ after tick: Int = 0,
+ emitsNotifications: Bool = false
+ ) async -> Int {
+ await runtime.appendPreviewStreamTick(
+ after: tick,
+ emitsNotifications: emitsNotifications
+ )
+ }
+
+ public func snapshotForTesting(chatID: CodexThreadID) async -> CodexThreadSnapshot? {
+ await runtime.snapshotForTesting(chatID: chatID)
+ }
+
+ public func interruptRequestCountForTesting() async -> Int {
+ await runtime.interruptRequestCountForTesting()
+ }
+
+ public func archiveRequestCountForTesting() async -> Int {
+ await runtime.archiveRequestCountForTesting()
+ }
+}
+
+@MainActor
+public enum ReviewMonitorPreviewContent {
+ fileprivate enum PreviewChatLifecycle {
+ case queued
+ case running
+ case succeeded
+ case failed
+ case cancelled
+ }
+
+ private struct PreviewChatDefinition {
+ let lifecycle: PreviewChatLifecycle
+ let targetSummary: String
+ let summary: String
+ let initialMessage: String
+ let model: String
+ let startedOffset: TimeInterval?
+ let endedOffset: TimeInterval?
+ }
+
+ private struct PreviewChatFixture {
+ let id: String
+ let chatID: CodexThreadID
+ let turnID: CodexTurnID
+ let cwd: String
+ let targetSummary: String
+ let summary: String
+ let initialMessage: String
+ let model: String
+ let lifecycle: PreviewChatLifecycle
+ let startedAt: Date?
+ let endedAt: Date?
+ let chatItems: [PreviewChatLogItemTemplate]
+ }
+
+ private enum PreviewReasoningStyle {
+ case raw
+ case summary
+ }
+
+ private struct PreviewStreamTemplate {
+ let itemName: String?
+ let kind: CodexThreadItem.Kind
+ let content: CodexThreadItem.Content
+ let mode: PreviewStreamMode
+ let deltaText: String?
+ let chunkByWord: Bool
+ let delayBeforeFrameCount: Int
+ let chunkIntervalFrameCount: Int
+
+ init(
+ itemName: String? = nil,
+ kind: CodexThreadItem.Kind,
+ content: CodexThreadItem.Content,
+ mode: PreviewStreamMode = .complete,
+ deltaText: String? = nil,
+ chunkByWord: Bool = false,
+ delayBeforeFrameCount: Int,
+ chunkIntervalFrameCount: Int = 1
+ ) {
+ self.itemName = itemName
+ self.kind = kind
+ self.content = content
+ self.mode = mode
+ self.deltaText = deltaText
+ self.chunkByWord = chunkByWord
+ self.delayBeforeFrameCount = delayBeforeFrameCount
+ self.chunkIntervalFrameCount = chunkIntervalFrameCount
+ }
+ }
+
+ package enum PreviewStreamMode {
+ case update
+ case complete
+ case textDelta
+ }
+
+ private struct PreviewChatLogItemTemplate {
+ let itemName: String
+ let kind: CodexThreadItem.Kind
+ let content: CodexThreadItem.Content
+
+ init(
+ itemName: String,
+ kind: CodexThreadItem.Kind,
+ content: CodexThreadItem.Content
+ ) {
+ self.itemName = itemName
+ self.kind = kind
+ self.content = content
+ }
+
+ func threadItem(id: String) -> CodexThreadItem {
+ CodexThreadItem(
+ id: id,
+ kind: kind,
+ content: content
+ )
+ }
+ }
+
+ package struct PreviewChatLogStreamStep {
+ let itemName: String
+ let kind: CodexThreadItem.Kind
+ let content: CodexThreadItem.Content
+ let mode: PreviewStreamMode
+ let deltaText: String?
+
+ func threadItem(id: String) -> CodexThreadItem {
+ CodexThreadItem(
+ id: id,
+ kind: kind,
+ content: content
+ )
+ }
+ }
+
+ package struct PreviewStreamFrame {
+ let step: PreviewChatLogStreamStep
+ let cycle: Int
+ }
+
+ public static func makeStore() -> CodexReviewStore {
+ let store = CodexReviewStore.makePreviewStore(
+ seed: .init(initialSettingsSnapshot: makePreviewSettingsSnapshot())
+ )
+ let accounts = makePreviewAccounts()
+ store.loadForTesting(
+ serverState: .running,
+ account: accounts.first,
+ persistedAccounts: accounts,
+ serverURL: URL(string: "http://localhost:9417/mcp")
+ )
+ return store
+ }
+
+ public static func makeContentSource() -> ReviewMonitorPreviewContentSource {
+ let chatLogFixtures = makeChatLogFixtures()
+ let store = makeStore()
+ let previewRuntime = ReviewMonitorPreviewAppServerRuntime(
+ fixtures: chatLogFixtures
+ )
+ return ReviewMonitorPreviewContentSource(
+ store: store,
+ runtime: previewRuntime
+ )
+ }
+
+ public static func makeCommandOutputStore() -> CodexReviewStore {
+ makeCommandOutputContentSource().store
+ }
+
+ public static func makeCommandOutputContentSource() -> ReviewMonitorPreviewContentSource {
+ let store = CodexReviewStore.makePreviewStore(
+ seed: .init(initialSettingsSnapshot: makePreviewSettingsSnapshot())
+ )
+ let accounts = makePreviewAccounts()
+ let cwd = "/path/to/workspace-alpha"
+ let now = Date()
+ let chatItems = makeCommandOutputPreviewChatLogItems()
+ let chatID = CodexThreadID(rawValue: "preview-command-output-panel")
+ let turnID = CodexTurnID(rawValue: "preview-command-output-turn")
+ let chatFixture = PreviewChatFixture(
+ id: "preview-command-output-panel",
+ chatID: chatID,
+ turnID: turnID,
+ cwd: cwd,
+ targetSummary: "Command output panel",
+ summary: "A command output block is collapsed by default.",
+ initialMessage: "Opened command output should stay bounded to a short embedded scroll view.",
+ model: "gpt-5.4",
+ lifecycle: .running,
+ startedAt: now.addingTimeInterval(-135),
+ endedAt: nil,
+ chatItems: chatItems
+ )
+ store.loadForTesting(
+ serverState: .running,
+ account: accounts.first,
+ persistedAccounts: accounts,
+ serverURL: URL(string: "http://localhost:9417/mcp")
+ )
+ let fixture = makeChatLogFixture(
+ for: chatFixture
+ )
+ return ReviewMonitorPreviewContentSource(
+ store: store,
+ runtime: ReviewMonitorPreviewAppServerRuntime(fixtures: [fixture])
+ )
+ }
+
+ package static func streamFrame(
+ forRunningChatAt runningChatIndex: Int,
+ tick: Int
+ ) -> PreviewStreamFrame? {
+ guard previewChatLogStreamSchedule.isEmpty == false else {
+ return nil
+ }
+ let chatTick = tick - runningChatIndex * previewChatTickOffset
+ guard chatTick > 0 else {
+ return nil
+ }
+
+ let offset = (chatTick - 1) % previewChatLogStreamSchedule.count
+ guard let step = previewChatLogStreamSchedule[offset] else {
+ return nil
+ }
+ return PreviewStreamFrame(
+ step: step,
+ cycle: (chatTick - 1) / previewChatLogStreamSchedule.count
+ )
+ }
+
+ package static func previewChatLogItemID(
+ itemName: String,
+ streamID: String,
+ cycle: Int
+ ) -> String {
+ "preview-\(itemName)-\(streamID)-\(cycle)"
+ }
+
+ private static func previewTurnID(_ tick: Int) -> String {
+ if tick < 10 {
+ return "preview-turn-00\(tick)"
+ }
+ if tick < 100 {
+ return "preview-turn-0\(tick)"
+ }
+ return "preview-turn-\(tick)"
+ }
+
+ private static func chatLogStreamSchedule(from templates: [PreviewStreamTemplate]) -> [PreviewChatLogStreamStep?] {
+ var schedule: [PreviewChatLogStreamStep?] = []
+ for (templateIndex, template) in templates.enumerated() {
+ let delay = schedule.isEmpty ? 1 : max(1, template.delayBeforeFrameCount)
+ if delay > 1 {
+ schedule.append(contentsOf: Array(repeating: nil, count: delay - 1))
+ }
+ let itemName = template.itemName ?? "stream-\(templateIndex)"
+ let streamText = template.deltaText ?? ""
+ let chunks = template.chunkByWord ? wordChunks(in: streamText) : [streamText]
+ for (index, chunk) in chunks.enumerated() {
+ if index > 0 && template.chunkIntervalFrameCount > 1 {
+ schedule.append(contentsOf: Array(repeating: nil, count: template.chunkIntervalFrameCount - 1))
+ }
+ schedule.append(
+ PreviewChatLogStreamStep(
+ itemName: itemName,
+ kind: template.kind,
+ content: template.content,
+ mode: template.mode,
+ deltaText: template.mode == .textDelta ? chunk : template.deltaText
+ ))
+ }
+ }
+ return schedule
+ }
+
+ private static func wordChunks(in text: String) -> [String] {
+ let words = text.split(separator: " ", omittingEmptySubsequences: false).map(String.init)
+ guard words.isEmpty == false else {
+ return [text]
+ }
+ return words.enumerated().map { offset, word in
+ if offset == words.count - 1 {
+ return word
+ }
+ return word + " "
+ }
+ }
+
+ private static let interItemDelayFrameCount = 39
+ private static let commandCompletionDelayFrameCount = 60
+ private static let compactionCompletionDelayFrameCount = 15
+ private static let previewChatTickOffset = 21
+
+ private static let previewStreamTemplates: [PreviewStreamTemplate] = [
+ .init(
+ kind: CodexThreadItem.Kind(rawValue: "event"),
+ content: .diagnostic("Turn started: \(previewTurnID(1))"),
+ delayBeforeFrameCount: 1
+ ),
+ .init(
+ itemName: "plan",
+ kind: .plan,
+ content: .plan(
+ """
+ [completed] Inspect ReviewMonitor log rendering
+ [in_progress] Preserve active find UI while streaming
+ [pending] Run focused UI tests
+ """),
+ delayBeforeFrameCount: interItemDelayFrameCount
+ ),
+ .init(
+ itemName: "command-search-test",
+ kind: .commandExecution,
+ content: .command(
+ .init(
+ command:
+ "/bin/zsh -lc \"rg -n 'ReviewMonitorLog' Sources/ReviewUI && swift test --filter ReviewUI\"",
+ status: .running
+ )),
+ mode: .update,
+ delayBeforeFrameCount: interItemDelayFrameCount
+ ),
+ .init(
+ itemName: "command-search-test",
+ kind: .commandExecution,
+ content: .command(
+ .init(
+ command: "",
+ output: """
+ Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift:42: private let logDocumentView = ReviewMonitorLogDocumentView()
+ Sources/ReviewUI/Detail/ReviewMonitorLogDocumentView.swift:20: final class ReviewMonitorLogDocumentView
+ Test Suite 'ReviewUITests' passed.
+ """,
+ exitCode: 0,
+ status: .completed
+ )),
+ delayBeforeFrameCount: commandCompletionDelayFrameCount
+ ),
+ .init(
+ kind: .mcpToolCall,
+ content: .toolCall(
+ .init(
+ result: "MCP codex_review.review_read started.",
+ status: .running
+ )),
+ delayBeforeFrameCount: interItemDelayFrameCount
+ ),
+ .init(
+ itemName: "reasoning-summary",
+ kind: .reasoning,
+ content: .reasoning(.init(summary: "")),
+ mode: .textDelta,
+ deltaText:
+ "Checking whether append-only log updates are notifying NSTextFinder while an incremental search is active.\n",
+ chunkByWord: true,
+ delayBeforeFrameCount: interItemDelayFrameCount
+ ),
+ .init(
+ itemName: "command-open-log-scroll",
+ kind: .commandExecution,
+ content: .command(
+ .init(
+ command:
+ "/bin/zsh -lc \"sed -n '1,240p' Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift\"",
+ status: .running
+ )),
+ mode: .update,
+ delayBeforeFrameCount: interItemDelayFrameCount
+ ),
+ .init(
+ itemName: "command-open-log-scroll",
+ kind: .commandExecution,
+ content: .command(
+ .init(
+ command: "",
+ output: """
+ import AppKit
+ import ObjectiveC.runtime
+ import CodexReviewKit
+
+ @MainActor
+ final class ReviewMonitorLogScrollView: NSScrollView {
+ private let logDocumentView = ReviewMonitorLogDocumentView()
+ """,
+ exitCode: 0,
+ status: .completed
+ )),
+ delayBeforeFrameCount: commandCompletionDelayFrameCount
+ ),
+ .init(
+ itemName: "context-compaction",
+ kind: .contextCompaction,
+ content: .contextCompaction("Automatically compacting context"),
+ mode: .update,
+ delayBeforeFrameCount: interItemDelayFrameCount
+ ),
+ .init(
+ itemName: "context-compaction",
+ kind: .contextCompaction,
+ content: .contextCompaction("Context automatically compacted"),
+ delayBeforeFrameCount: compactionCompletionDelayFrameCount
+ ),
+ .init(
+ kind: .mcpToolCall,
+ content: .toolCall(
+ .init(
+ result: "File changes updated.",
+ status: .completed
+ )),
+ delayBeforeFrameCount: interItemDelayFrameCount
+ ),
+ .init(
+ itemName: "raw-reasoning",
+ kind: .reasoning,
+ content: .reasoning(.init(content: "")),
+ mode: .textDelta,
+ deltaText:
+ "Need to avoid refreshing the finder client string until the user closes or clears the find bar. Appended text can wait for the next search session.\n",
+ chunkByWord: true,
+ delayBeforeFrameCount: interItemDelayFrameCount
+ ),
+ .init(
+ itemName: "raw-reasoning-follow-up",
+ kind: .reasoning,
+ content: .reasoning(.init(content: "")),
+ mode: .textDelta,
+ deltaText:
+ "I found the log update path and am keeping the current find session stable while new output streams in.\n",
+ chunkByWord: true,
+ delayBeforeFrameCount: interItemDelayFrameCount
+ ),
+ .init(
+ itemName: "agent-message-summary",
+ kind: .agentMessage,
+ content: .message(
+ .init(
+ id: "agent-message-summary",
+ role: .assistant,
+ text:
+ "The preview stream now mixes commands, tool events, reasoning summaries, and visible assistant output instead of one repeated message kind.\n",
+ )),
+ delayBeforeFrameCount: interItemDelayFrameCount
+ ),
+ ]
+
+ private static let previewChatLogStreamSchedule = chatLogStreamSchedule(from: previewStreamTemplates)
+
+ private static func makeCommandOutputPreviewChatLogItems() -> [PreviewChatLogItemTemplate] {
+ let output = """
+ Command line invocation:
+ /Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild test -project Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj -scheme CodexReviewMonitor
+
+ Resolve Package Graph
+
+ Resolved source packages:
+ ObservationBridge: /Users/kn/Dev/ObservationBridge
+ CodexReviewKit: /Users/kn/Dev/CodexReviewKit
+
+ Test Suite 'Selected tests' started.
+ Test Case '-[ReviewUITests commandOutputRendersCollapsedTextKitPanel]' passed (0.142 seconds).
+ Test Suite 'Selected tests' passed.
+ """
+ return [
+ diagnosticItem(
+ "command-output-event",
+ kind: CodexThreadItem.Kind(rawValue: "event"),
+ message: "Turn started: preview-command-output-panel"
+ ),
+ messageItem(
+ "command-output-intro",
+ text: """
+ Checking the command output rendering path.
+
+ - Keep the transcript readable.
+ - Collapse terminal output by default.
+ - Expand into a bounded TextKit 2 scroll view.
+ """
+ ),
+ commandStartedItem(
+ "preview-command-output",
+ command:
+ "xcodebuild test -project Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj -scheme CodexReviewMonitor"
+ ),
+ commandCompletedItem(
+ "preview-command-output",
+ output: output,
+ exitCode: 0,
+ status: .completed
+ ),
+ messageItem(
+ "command-output-summary", text: "The output remains available without taking over the whole log."),
+ ]
+ }
+
+ public static func makePreviewAccounts() -> [CodexReviewAccount] {
+ [
+ makePreviewAccount(
+ email: "workspace@example.com",
+ usedPercents: (short: 34, long: 61)
+ ),
+ makePreviewAccount(
+ email: "personal@example.com",
+ usedPercents: (short: 12, long: 27)
+ ),
+ makePreviewAccount(
+ email: "team@example.com",
+ usedPercents: (short: 72, long: 44)
+ ),
+ ]
+ }
+
+ public static func makePreviewAccount(
+ email: String = "review@example.com",
+ usedPercents: (short: Int, long: Int) = (short: 34, long: 61)
+ ) -> CodexReviewAccount {
+ let account = CodexReviewAccount(email: email, planType: "pro")
+ account.updateRateLimits(
+ [
+ (
+ windowDurationMinutes: 300,
+ usedPercent: usedPercents.short,
+ resetsAt: Date.now.addingTimeInterval(60 * 60)
+ ),
+ (
+ windowDurationMinutes: 10_080,
+ usedPercent: usedPercents.long,
+ resetsAt: Date.now.addingTimeInterval(24 * 60 * 60)
+ ),
+ ]
+ )
+ return account
+ }
+
+ static func makePreviewSettingsSnapshot() -> CodexReviewSettings.Snapshot {
+ .init(
+ model: "gpt-5.4",
+ reasoningEffort: .medium,
+ serviceTier: .fast,
+ models: makePreviewModelCatalog()
+ )
+ }
+
+ static func makePreviewModelCatalog() -> [CodexReviewSettings.ModelCatalogItem] {
+ [
+ .init(
+ id: "gpt-5.4",
+ model: "gpt-5.4",
+ displayName: "GPT-5.4",
+ hidden: false,
+ supportedReasoningEfforts: [
+ .init(reasoningEffort: .low, description: "Lower latency."),
+ .init(reasoningEffort: .medium, description: "Balanced default."),
+ .init(reasoningEffort: .high, description: "More deliberation."),
+ ],
+ defaultReasoningEffort: .medium,
+ supportedServiceTiers: [.fast]
+ ),
+ .init(
+ id: "gpt-5.4-mini",
+ model: "gpt-5.4-mini",
+ displayName: "GPT-5.4 Mini",
+ hidden: false,
+ supportedReasoningEfforts: [
+ .init(reasoningEffort: .low, description: "Quick pass."),
+ .init(reasoningEffort: .medium, description: "Balanced default."),
+ ],
+ defaultReasoningEffort: .medium,
+ supportedServiceTiers: []
+ ),
+ .init(
+ id: "gpt-5.3-codex",
+ model: "gpt-5.3-codex",
+ displayName: "GPT-5.3 Codex",
+ hidden: false,
+ supportedReasoningEfforts: [
+ .init(reasoningEffort: .minimal, description: "Lowest overhead."),
+ .init(reasoningEffort: .low, description: "Faster iteration."),
+ .init(reasoningEffort: .medium, description: "Balanced default."),
+ ],
+ defaultReasoningEffort: .medium,
+ supportedServiceTiers: [.fast, .flex]
+ ),
+ ]
+ }
+
+ private static func makeChatLogFixtures() -> [ReviewMonitorPreviewChatLogFixture] {
+ let now = Date()
+ let workspacePaths = [
+ "/path/to/workspace-alpha",
+ "/path/to/workspace-beta",
+ "/path/to/workspace-gamma",
+ ]
+
+ var chatLogFixtures: [ReviewMonitorPreviewChatLogFixture] = []
+ for (workspaceIndex, cwd) in workspacePaths.enumerated() {
+ let workspaceName = URL(fileURLWithPath: cwd).lastPathComponent
+ for (chatIndex, definition) in makeChatDefinitions(for: workspaceName).enumerated() {
+ let chatItems = makePreviewChatLogItems(for: definition, workspaceName: workspaceName)
+ let chatID = CodexThreadID(rawValue: "preview-thread-\(workspaceIndex)-\(chatIndex)")
+ let turnID = CodexTurnID(rawValue: "preview-turn-\(workspaceIndex)-\(chatIndex)")
+ let chatFixture = PreviewChatFixture(
+ id: "preview-\(workspaceIndex)-\(chatIndex)",
+ chatID: chatID,
+ turnID: turnID,
+ cwd: cwd,
+ targetSummary: definition.targetSummary,
+ summary: definition.summary,
+ initialMessage: definition.initialMessage,
+ model: definition.model,
+ lifecycle: definition.lifecycle,
+ startedAt: definition.startedOffset.map { now.addingTimeInterval($0) },
+ endedAt: definition.endedOffset.map { now.addingTimeInterval($0) },
+ chatItems: chatItems
+ )
+ chatLogFixtures.append(
+ makeChatLogFixture(
+ for: chatFixture
+ ))
+ }
+ }
+ return chatLogFixtures
+ }
+
+ private static func makeChatLogFixture(
+ for chatFixture: PreviewChatFixture
+ ) -> ReviewMonitorPreviewChatLogFixture {
+ let turn = CodexTurnSnapshot(
+ id: chatFixture.turnID,
+ status: CodexTurnStatus(chatFixture.lifecycle),
+ errorMessage: chatFixture.lifecycle == .failed ? chatFixture.summary : nil,
+ items: makeInitialChatItems(
+ streamID: chatFixture.id,
+ chatItems: chatFixture.chatItems
+ )
+ )
+ let initialThreadSnapshot = CodexThreadSnapshot(
+ id: chatFixture.chatID,
+ turns: [turn]
+ )
+ return ReviewMonitorPreviewChatLogFixture(
+ chatID: chatFixture.chatID,
+ title: chatFixture.targetSummary,
+ preview: chatFixture.initialMessage.nilIfEmpty ?? chatFixture.summary.nilIfEmpty,
+ model: chatFixture.model,
+ workspaceCWD: chatFixture.cwd,
+ updatedAt: chatFixture.endedAt ?? chatFixture.startedAt,
+ recencyAt: chatFixture.endedAt ?? chatFixture.startedAt,
+ status: CodexThreadStatus(previewLifecycle: chatFixture.lifecycle),
+ cwd: chatFixture.cwd,
+ streamID: chatFixture.id,
+ isRunning: chatFixture.lifecycle == .running,
+ initialThreadSnapshot: initialThreadSnapshot
+ )
+ }
+
+ private static func makeInitialChatItems(
+ streamID: String,
+ chatItems: [PreviewChatLogItemTemplate]
+ ) -> [CodexThreadItem] {
+ var items: [CodexThreadItem] = []
+ for item in chatItems {
+ let threadItem = item.threadItem(
+ id: previewChatLogItemID(
+ itemName: item.itemName,
+ streamID: streamID,
+ cycle: 0
+ )
+ )
+ if let index = items.firstIndex(where: {
+ $0.id == threadItem.id
+ }) {
+ items[index] = threadItem
+ } else {
+ items.append(threadItem)
+ }
+ }
+ return items
+ }
+
+ private static func diagnosticItem(
+ _ itemName: String,
+ kind: CodexThreadItem.Kind,
+ message: String
+ ) -> PreviewChatLogItemTemplate {
+ .init(
+ itemName: itemName,
+ kind: kind,
+ content: .diagnostic(message)
+ )
+ }
+
+ private static func messageItem(_ itemName: String, text: String) -> PreviewChatLogItemTemplate {
+ .init(
+ itemName: itemName,
+ kind: .agentMessage,
+ content: .message(.init(id: itemName, role: .assistant, text: text))
+ )
+ }
+
+ private static func planItem(_ itemName: String, markdown: String) -> PreviewChatLogItemTemplate {
+ .init(
+ itemName: itemName,
+ kind: .plan,
+ content: .plan(markdown)
+ )
+ }
+
+ private static func reasoningItem(
+ _ itemName: String,
+ text: String,
+ style: PreviewReasoningStyle = .summary
+ ) -> PreviewChatLogItemTemplate {
+ .init(
+ itemName: itemName,
+ kind: .reasoning,
+ content: .reasoning(style == .summary ? .init(summary: text) : .init(content: text))
+ )
+ }
+
+ private static func contextCompactionItem(
+ _ itemName: String,
+ title: String
+ ) -> PreviewChatLogItemTemplate {
+ .init(
+ itemName: itemName,
+ kind: .contextCompaction,
+ content: .contextCompaction(title)
+ )
+ }
+
+ private static func commandStartedItem(
+ _ itemName: String,
+ command: String,
+ cwd: String? = nil
+ ) -> PreviewChatLogItemTemplate {
+ .init(
+ itemName: itemName,
+ kind: .commandExecution,
+ content: .command(
+ .init(
+ command: command,
+ cwd: cwd,
+ status: .running
+ ))
+ )
+ }
+
+ private static func commandCompletedItem(
+ _ itemName: String,
+ output: String,
+ exitCode: Int,
+ status: CodexTurnStatus
+ ) -> PreviewChatLogItemTemplate {
+ .init(
+ itemName: itemName,
+ kind: .commandExecution,
+ content: .command(
+ .init(
+ command: "",
+ output: output,
+ exitCode: exitCode,
+ status: status
+ ))
+ )
+ }
+
+ private static func toolCallItem(
+ _ itemName: String,
+ result: String,
+ status: CodexTurnStatus
+ ) -> PreviewChatLogItemTemplate {
+ return PreviewChatLogItemTemplate(
+ itemName: itemName,
+ kind: .mcpToolCall,
+ content: .toolCall(.init(result: result, status: status))
+ )
+ }
+
+ private static func makePreviewChatLogItems(
+ for definition: PreviewChatDefinition,
+ workspaceName: String
+ ) -> [PreviewChatLogItemTemplate] {
+ switch definition.lifecycle {
+ case .running:
+ return makeRunningPreviewChatLogItems(for: definition, workspaceName: workspaceName)
+ case .queued:
+ return [
+ diagnosticItem(
+ "queued-event-\(workspaceName)-\(definition.targetSummary)",
+ kind: CodexThreadItem.Kind(rawValue: "event"),
+ message: "Queued review for \(definition.targetSummary)."
+ ),
+ diagnosticItem(
+ "queued-progress-\(workspaceName)-\(definition.targetSummary)",
+ kind: CodexThreadItem.Kind(rawValue: "progress"),
+ message: definition.summary
+ ),
+ ]
+ case .failed:
+ let commandName = "preview-failed-command-\(workspaceName)-\(definition.targetSummary)"
+ return [
+ diagnosticItem(
+ "failed-event-\(workspaceName)-\(definition.targetSummary)",
+ kind: CodexThreadItem.Kind(rawValue: "event"),
+ message: "Turn started: preview-failed-\(workspaceName.lowercased())"
+ ),
+ commandStartedItem(
+ commandName,
+ command: "/bin/zsh -lc \"swift test --build-system swiftbuild --no-parallel\""
+ ),
+ commandCompletedItem(
+ commandName,
+ output: """
+ Building for debugging...
+ Test Suite 'ReviewUITests' started.
+ ReviewMonitorContentPreviewTests.testPreviewStore failed: expected command output panel metadata.
+ """,
+ exitCode: 1,
+ status: .failed
+ ),
+ diagnosticItem(
+ "failed-error-\(workspaceName)-\(definition.targetSummary)",
+ kind: .error,
+ message: definition.summary
+ ),
+ messageItem(
+ "failed-message-\(workspaceName)-\(definition.targetSummary)", text: definition.initialMessage),
+ ]
+ case .cancelled:
+ return [
+ diagnosticItem(
+ "cancelled-event-\(workspaceName)-\(definition.targetSummary)",
+ kind: CodexThreadItem.Kind(rawValue: "event"),
+ message: "Turn started: preview-cancelled-\(workspaceName.lowercased())"
+ ),
+ diagnosticItem(
+ "cancelled-progress-\(workspaceName)-\(definition.targetSummary)",
+ kind: CodexThreadItem.Kind(rawValue: "progress"),
+ message: definition.summary
+ ),
+ messageItem(
+ "cancelled-message-\(workspaceName)-\(definition.targetSummary)", text: definition.initialMessage),
+ ]
+ case .succeeded:
+ let commandName = "preview-complete-command-\(workspaceName)-\(definition.targetSummary)"
+ return [
+ diagnosticItem(
+ "complete-event-\(workspaceName)-\(definition.targetSummary)",
+ kind: CodexThreadItem.Kind(rawValue: "event"),
+ message: "Turn started: preview-complete-\(workspaceName.lowercased())"
+ ),
+ commandStartedItem(
+ commandName,
+ command: "/bin/zsh -lc \"swift test --filter ReviewUI\""
+ ),
+ commandCompletedItem(
+ commandName,
+ output: """
+ Test Suite 'ReviewUITests' started.
+ Test commandOutputRendersCollapsedTextKitPanelAndExpandsInline passed.
+ Test Suite 'ReviewUITests' passed.
+ """,
+ exitCode: 0,
+ status: .completed
+ ),
+ reasoningItem(
+ "complete-summary-\(workspaceName)-\(definition.targetSummary)",
+ text: definition.summary
+ ),
+ messageItem(
+ "complete-message-\(workspaceName)-\(definition.targetSummary)", text: definition.initialMessage),
+ ]
+ }
+ }
+
+ private static func makeRunningPreviewChatLogItems(
+ for definition: PreviewChatDefinition,
+ workspaceName: String
+ ) -> [PreviewChatLogItemTemplate] {
+ let sourceReadItemName = "preview-initial-source-read-\(workspaceName)-\(definition.targetSummary)"
+ let sourceReadCommand =
+ "sed -n '1,260p' Sources/ReviewUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift"
+ let initialCommandName = "preview-initial-command-\(workspaceName)-\(definition.targetSummary)"
+ return [
+ diagnosticItem(
+ "running-event-\(workspaceName)-\(definition.targetSummary)",
+ kind: CodexThreadItem.Kind(rawValue: "event"),
+ message: "Turn started: preview-\(workspaceName.lowercased())"
+ ),
+ diagnosticItem(
+ "running-progress-\(workspaceName)-\(definition.targetSummary)",
+ kind: CodexThreadItem.Kind(rawValue: "progress"),
+ message: "Reviewing \(definition.targetSummary)"
+ ),
+ contextCompactionItem(
+ "preview-initial-context-compaction-\(workspaceName)-\(definition.targetSummary)",
+ title: "Context automatically compacted"
+ ),
+ planItem(
+ "preview-initial-plan-\(workspaceName)-\(definition.targetSummary)",
+ markdown: """
+ [completed] Inspect changed files
+ [in_progress] Check ReviewMonitor log behavior
+ [pending] Run focused tests
+ """
+ ),
+ commandStartedItem(
+ initialCommandName,
+ command: "/bin/zsh -lc \"git diff --stat && rg -n 'ReviewMonitor' Sources Tests\""
+ ),
+ commandCompletedItem(
+ initialCommandName,
+ output: """
+ Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift | 34 +++++++++++++++++
+ Sources/ReviewUI/Detail/ReviewMonitorLogDocumentView.swift | 18 ++++++++--
+ Tests/ReviewUITests/ReviewUITests.swift | 12 ++++++
+
+ Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift:42: private let logDocumentView = ReviewMonitorLogDocumentView()
+ Tests/ReviewUITests/ReviewUITests.swift:2322: commandOutputRendersCollapsedTextKitPanelAndExpandsInline
+ """,
+ exitCode: 0,
+ status: .completed
+ ),
+ commandStartedItem(
+ sourceReadItemName,
+ command: "/bin/zsh -lc \"\(sourceReadCommand)\""
+ ),
+ toolCallItem(
+ "running-tool-\(workspaceName)-\(definition.targetSummary)",
+ result: "MCP codex_review.review_start started.",
+ status: .running
+ ),
+ reasoningItem(
+ "preview-initial-summary-\(workspaceName)-\(definition.targetSummary)",
+ text:
+ "I am comparing the current UI state with the streaming log updates before changing the finder integration."
+ ),
+ messageItem(
+ "preview-initial-agent-\(workspaceName)-\(definition.targetSummary)", text: definition.initialMessage),
+ ]
+ }
+
+ private static func makeChatDefinitions(for workspaceName: String) -> [PreviewChatDefinition] {
+ [
+ PreviewChatDefinition(
+ lifecycle: .running,
+ targetSummary: "Branch: feature/\(workspaceName.lowercased())-sidebar",
+ summary: "Review is streaming updates from the embedded server.",
+ initialMessage: "Inspecting recent sidebar changes and collecting render timings.",
+ model: "gpt-5.4",
+ startedOffset: -420,
+ endedOffset: nil
+ ),
+ PreviewChatDefinition(
+ lifecycle: .running,
+ targetSummary: "Uncommitted changes",
+ summary: "The working tree review is still in progress.",
+ initialMessage: "Comparing row reuse behavior across the latest local edits.",
+ model: "gpt-5.4-mini",
+ startedOffset: -135,
+ endedOffset: nil
+ ),
+ PreviewChatDefinition(
+ lifecycle: .queued,
+ targetSummary: "Base branch: main",
+ summary: "Queued behind another active review in this workspace.",
+ initialMessage: "Waiting for an available backend slot.",
+ model: "gpt-5.3-codex",
+ startedOffset: nil,
+ endedOffset: nil
+ ),
+ PreviewChatDefinition(
+ lifecycle: .succeeded,
+ targetSummary: "Commit: abc1234",
+ summary: "Review completed without correctness findings.",
+ initialMessage: "No correctness issues found in the touched files.",
+ model: "gpt-5.4",
+ startedOffset: -1_500,
+ endedOffset: -1_260
+ ),
+ PreviewChatDefinition(
+ lifecycle: .failed,
+ targetSummary: "Custom: investigate CI flake",
+ summary: "The review stopped after the test command failed.",
+ initialMessage: "Build failed before the model could finish evaluating the patch.",
+ model: "gpt-5.3-codex",
+ startedOffset: -2_400,
+ endedOffset: -2_190
+ ),
+ PreviewChatDefinition(
+ lifecycle: .cancelled,
+ targetSummary: "Branch: feature/\(workspaceName.lowercased())-transport",
+ summary: "Cancellation was requested after initial diagnostics completed.",
+ initialMessage: "Stopped after the first pass to free the session for a retry.",
+ model: "gpt-5.4-mini",
+ startedOffset: -960,
+ endedOffset: -840
+ ),
+ PreviewChatDefinition(
+ lifecycle: .succeeded,
+ targetSummary: "Commit: def5678",
+ summary: "Review suggested a small cleanup in the sidebar row renderer.",
+ initialMessage: "Suggested simplifying duplicated state handling in the row view.",
+ model: "gpt-5.4",
+ startedOffset: -5_400,
+ endedOffset: -5_040
+ ),
+ ]
+ }
+}
+
+private extension CodexThreadStatus {
+ init(previewLifecycle lifecycle: ReviewMonitorPreviewContent.PreviewChatLifecycle) {
+ switch lifecycle {
+ case .queued, .running:
+ self = .active(activeFlags: [])
+ case .succeeded, .failed, .cancelled:
+ self = .idle
+ }
+ }
+}
+
+private extension CodexTurnStatus {
+ init(_ lifecycle: ReviewMonitorPreviewContent.PreviewChatLifecycle) {
+ switch lifecycle {
+ case .queued, .running:
+ self = .running
+ case .succeeded:
+ self = .completed
+ case .failed:
+ self = .failed
+ case .cancelled:
+ self = .cancelled
+ }
+ }
+}
+
+private extension CodexDataPhase {
+ init(_ lifecycle: ReviewMonitorPreviewContent.PreviewChatLifecycle, errorMessage: String?) {
+ switch lifecycle {
+ case .queued, .running, .succeeded, .cancelled:
+ self = .loaded
+ case .failed:
+ self = .failed(errorMessage ?? "Review failed")
+ }
+ }
+}
diff --git a/Tests/ArchitectureFenceTests/ArchitectureFenceTests.swift b/Tests/ArchitectureFenceTests/ArchitectureFenceTests.swift
deleted file mode 100644
index e4a7d9fc..00000000
--- a/Tests/ArchitectureFenceTests/ArchitectureFenceTests.swift
+++ /dev/null
@@ -1,310 +0,0 @@
-import Foundation
-import Testing
-
-@Suite("architecture fence")
-struct ArchitectureFenceTests {
- @Test func finalTargetsDoNotImportForbiddenImplementationTargets() throws {
- let violations = try Self.importViolations(in: [
- .init(
- root: "Sources",
- forbiddenImports: [
- "ReviewApplication",
- "ReviewDomain",
- "ReviewPorts",
- "ReviewServiceRuntime",
- "ReviewNativeAuthAdapter",
- "ReviewHTTPServerAdapter",
- "ReviewStdioAdapter",
- "ReviewTestSupport",
- "ReviewServiceRuntimeTestSupport",
- "ReviewAppServerAdapter",
- ]
- ),
- ])
-
- Self.expectNoViolations(violations)
- }
-
- @Test func codexReviewTargetDoesNotOwnReviewMonitorRenderingArtifacts() throws {
- let repo = Self.repositoryRoot
- let codexReviewSources = repo.appending(path: "Sources/CodexReview")
-
- var violations: [String] = []
- for file in try Self.swiftSourceFiles(in: codexReviewSources) {
- let url = codexReviewSources.appending(path: file)
- let text = try String(contentsOf: url, encoding: .utf8)
- if text.contains("ReviewMonitor") {
- violations.append(file)
- }
- }
-
- Self.expectNoViolations(violations)
- }
-
- @Test func reviewUIDoesNotMutateStoreOwnedJobStateDirectly() throws {
- let repo = Self.repositoryRoot
- let checkedRoots = [
- repo.appending(path: "Sources/ReviewUI"),
- repo.appending(path: "Tests/ReviewUITests"),
- ]
- let forbiddenAssignments = try [
- NSRegularExpression(pattern: #"\.core(?:\.[A-Za-z_][A-Za-z0-9_]*)*\s*=(?!=)"#),
- NSRegularExpression(pattern: #"\.targetSummary\s*=(?!=)"#),
- NSRegularExpression(pattern: #"\.cancellationRequested\s*=(?!=)"#),
- NSRegularExpression(pattern: #"\.sortOrder\s*=(?!=)"#),
- ]
-
- var violations: [String] = []
- for root in checkedRoots {
- for file in try Self.swiftSourceFiles(in: root) {
- let url = root.appending(path: file)
- let text = try String(contentsOf: url, encoding: .utf8)
- for (offset, line) in text.split(separator: "\n", omittingEmptySubsequences: false).enumerated() {
- let lineText = String(line)
- guard forbiddenAssignments.contains(where: {
- let range = NSRange(lineText.startIndex..
- }
-
- private static var repositoryRoot: URL {
- URL(fileURLWithPath: #filePath)
- .deletingLastPathComponent()
- .deletingLastPathComponent()
- .deletingLastPathComponent()
- }
-
- private static func swiftSourceFiles(in root: URL) throws -> [String] {
- try FileManager.default
- .subpathsOfDirectory(atPath: root.path)
- .filter { $0.hasSuffix(".swift") }
- .sorted()
- }
-
- private static func importViolations(in rules: [ImportBoundaryRule]) throws -> [String] {
- var violations: [String] = []
- for rule in rules {
- let root = repositoryRoot.appending(path: rule.root)
- for file in try swiftSourceFiles(in: root) {
- let url = root.appending(path: file)
- let text = try String(contentsOf: url, encoding: .utf8)
- for (offset, line) in text.split(separator: "\n", omittingEmptySubsequences: false).enumerated() {
- let forbiddenImports = importedModules(from: line)
- .filter { rule.forbiddenImports.contains($0) }
- for _ in forbiddenImports {
- violations.append("\(rule.root)/\(file):\(offset + 1): \(line.trimmingCharacters(in: .whitespaces))")
- }
- }
- }
- }
- return violations
- }
-
- private static func importedModules(from line: Substring) -> [String] {
- line.split(separator: ";").compactMap(importedModule(fromImportStatement:))
- }
-
- private static func importedModule(fromImportStatement statement: Substring) -> String? {
- let trimmed = statement.trimmingCharacters(in: .whitespaces)
- guard trimmed.isEmpty == false,
- trimmed.hasPrefix("//") == false
- else {
- return nil
- }
-
- let tokens = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" })
- guard let importIndex = tokens.firstIndex(of: "import") else {
- return nil
- }
-
- guard tokens[.. Bool {
- token.hasPrefix("@") || importModifiers.contains(String(token))
- }
-
- private static let importModifiers: Set = [
- "public",
- "internal",
- "package",
- "private",
- "fileprivate",
- ]
-
- private static let importKinds: Set = [
- "class",
- "enum",
- "func",
- "let",
- "operator",
- "protocol",
- "struct",
- "typealias",
- "var",
- ]
-
- private static func expectNoViolations(_ violations: [String]) {
- #expect(violations.isEmpty, Comment(rawValue: violations.joined(separator: "\n")))
- }
-}
diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift
index 119f1fc5..6a310848 100644
--- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift
+++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift
@@ -1,5850 +1,574 @@
-import Darwin
import Foundation
import Testing
+import CodexAppServerKit
+import CodexAppServerKitTesting
@testable import CodexReviewAppServer
-import CodexReview
-import CodexReviewDomain
-import CodexReviewTesting
+import CodexReviewKit
-private extension AppServerCodexReviewBackend {
- func resumeReviewRecovery(
- _ run: CodexReviewBackendModel.Review.Run,
- request: CodexReviewBackendModel.Review.Start,
- reason: CodexReviewBackendModel.CancellationReason
- ) async throws -> BackendReviewAttempt {
- let token = try await beginReviewRecovery(run, reason: reason)
- return try await resumeReviewRecovery(token, request: request)
- }
-
- func resumeReviewRecovery(
- _ attempt: BackendReviewAttempt,
- request: CodexReviewBackendModel.Review.Start,
- reason: CodexReviewBackendModel.CancellationReason
- ) async throws -> BackendReviewAttempt {
- try await resumeReviewRecovery(attempt.run, request: request, reason: reason)
- }
-
- func interruptReview(_ attempt: BackendReviewAttempt, reason: CodexReviewBackendModel.CancellationReason) async throws {
- try await interruptReview(attempt.run, reason: reason)
- }
-
- func beginReviewRecovery(
- _ attempt: BackendReviewAttempt,
- reason: CodexReviewBackendModel.CancellationReason
- ) async throws -> CodexReviewBackendModel.Review.RecoveryToken {
- try await beginReviewRecovery(attempt.run, reason: reason)
- }
-
- func cleanupReview(_ attempt: BackendReviewAttempt) async {
- await cleanupReview(attempt.run)
- }
-}
-
-private extension BackendReviewAttempt {
- var attemptID: String { run.attemptID }
- var threadID: String { run.threadID }
- var turnID: String? { run.turnID }
- var reviewThreadID: String? { run.reviewThreadID }
- var model: String? { run.model }
-}
-
-private struct BackendReviewEventSequence: AsyncSequence {
- typealias Element = CodexReviewBackendModel.Review.Event
-
- struct AsyncIterator: AsyncIteratorProtocol {
- var mailbox: BackendReviewEventMailbox
- var includesDomainEvents: Bool
-
- mutating func next() async throws -> CodexReviewBackendModel.Review.Event? {
- while let event = try await mailbox.next() {
- if includesDomainEvents {
- return event
- }
- if case .domainEvents = event {
- continue
- }
- if case .suppressNextLegacyTimelineProjection = event {
- continue
- }
- if case .suppressNextTerminalFailureLogTimelineProjection = event {
- continue
- }
- return event
- }
- return nil
- }
- }
-
- var mailbox: BackendReviewEventMailbox
- var includesDomainEvents: Bool
-
- func makeAsyncIterator() -> AsyncIterator {
- AsyncIterator(mailbox: mailbox, includesDomainEvents: includesDomainEvents)
- }
-}
-
-private func eventSequence(
- _ backend: AppServerCodexReviewBackend,
- _ attempt: BackendReviewAttempt,
- includingDomainEvents: Bool = false
-) async -> BackendReviewEventSequence {
- BackendReviewEventSequence(mailbox: attempt.events, includesDomainEvents: includingDomainEvents)
-}
-
-private func eventSequence(
- _ backend: AppServerCodexReviewBackend,
- _ run: CodexReviewBackendModel.Review.Run,
- includingDomainEvents: Bool = false
-) async -> BackendReviewEventSequence {
- let attempt = await backend.reviewAttemptForTesting(run)
- return BackendReviewEventSequence(mailbox: attempt.events, includesDomainEvents: includingDomainEvents)
-}
-
-@Suite("app-server client")
-struct AppServerClientTests {
- @Test func processTransportConfigurationResolvesCodexFromProvidedPath() throws {
- let directory = FileManager.default.temporaryDirectory
- .appending(path: "codex-review-transport-\(UUID().uuidString)")
- try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
- defer {
- try? FileManager.default.removeItem(at: directory)
- }
- let codex = directory.appending(path: "codex")
- let script = """
- #!/bin/sh
- if [ "$1" = "app-server" ] && [ "$2" = "--help" ]; then
- printf 'Usage: codex app-server --listen --session-source \\n'
- fi
- """
- try Data(script.utf8).write(to: codex)
- try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: codex.path)
-
- let configuration = AppServerProcessTransport.Configuration(
- environment: ["PATH": directory.path, "HOME": "/tmp/review-home"]
- )
-
- #expect(configuration.executable == codex.path)
- #expect(configuration.arguments == [
- "-c", CodexAppServerExecutable.fileBackedAuthConfiguration,
- "app-server",
- "--listen", "stdio://",
- "--session-source", "app-server",
- ])
- #expect(configuration.arguments.contains(#"cli_auth_credentials_store="file""#))
- #expect(configuration.threadStartPermissionStrategy == .modernPermissions)
- let sessionSourceIndex = try #require(configuration.arguments.firstIndex(of: "--session-source"))
- #expect(configuration.arguments[sessionSourceIndex + 1] == "app-server")
- }
-
- @Test func processTransportConfigurationUsesExplicitExecutableWithoutPathSearch() throws {
- let directory = FileManager.default.temporaryDirectory
- .appending(path: "codex-review-explicit-executable-\(UUID().uuidString)")
- try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
- defer {
- try? FileManager.default.removeItem(at: directory)
- }
- let codex = directory.appending(path: "custom-codex")
- let script = """
- #!/bin/sh
- if [ "$1" = "app-server" ] && [ "$2" = "--help" ]; then
- printf 'Usage: custom codex app-server --listen \\n'
- fi
- """
- try Data(script.utf8).write(to: codex)
- try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: codex.path)
-
- let configuration = AppServerProcessTransport.Configuration(
- executable: codex.path,
- environment: [
- "PATH": "/tmp/not-used",
- "HOME": "/tmp/review-home",
- ]
- )
-
- #expect(configuration.executable == codex.path)
- #expect(configuration.arguments == CodexAppServerExecutable.appServerArguments())
- }
-
- @Test func processTransportConfigurationOmitsUnsupportedSessionSourceFlag() throws {
- let directory = FileManager.default.temporaryDirectory
- .appending(path: "codex-review-transport-\(UUID().uuidString)")
- try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
- defer {
- try? FileManager.default.removeItem(at: directory)
- }
- let codex = directory.appending(path: "codex")
- let script = """
- #!/bin/sh
- if [ "$1" = "app-server" ] && [ "$2" = "--help" ]; then
- printf 'Usage: codex app-server --listen \\n'
- fi
- """
- try Data(script.utf8).write(to: codex)
- try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: codex.path)
-
- let configuration = AppServerProcessTransport.Configuration(
- environment: ["PATH": directory.path, "HOME": "/tmp/review-home"]
- )
-
- #expect(configuration.executable == codex.path)
- #expect(configuration.arguments == CodexAppServerExecutable.appServerArguments())
- #expect(configuration.arguments.contains("--session-source") == false)
- #expect(configuration.threadStartPermissionStrategy == .legacySandbox)
- }
-
- @Test func processTransportConfigurationDoesNotProbeWhenArgumentsAreExplicit() throws {
- let directory = FileManager.default.temporaryDirectory
- .appending(path: "codex-review-transport-\(UUID().uuidString)")
- try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
- defer {
- try? FileManager.default.removeItem(at: directory)
- }
- let codex = directory.appending(path: "codex")
- let probed = directory.appending(path: "probed")
- let script = """
- #!/bin/sh
- touch "\(probed.path)"
- """
- try Data(script.utf8).write(to: codex)
- try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: codex.path)
-
- let configuration = AppServerProcessTransport.Configuration(
- executable: codex.path,
- arguments: ["custom", "argument"],
- environment: ["PATH": directory.path, "HOME": "/tmp/review-home"]
- )
-
- #expect(configuration.executable == codex.path)
- #expect(configuration.arguments == ["custom", "argument"])
- #expect(configuration.threadStartPermissionStrategy == .legacySandbox)
- #expect(FileManager.default.fileExists(atPath: probed.path) == false)
- }
-
- @Test func processTransportSearchesCodexAppBundleResourcesFallback() throws {
- let directories = CodexAppServerExecutable.pathSearchDirectories(
- environment: ["PATH": "/tmp/codex-bin:/usr/bin"]
- )
-
- #expect(directories.contains("/Applications/Codex.app/Contents/Resources"))
- #expect(directories.filter { $0 == "/usr/bin" }.count == 1)
- let appBundleIndex = try #require(directories.firstIndex(of: "/Applications/Codex.app/Contents/Resources"))
- let homebrewIndex = try #require(directories.firstIndex(of: "/opt/homebrew/bin"))
- #expect(appBundleIndex < homebrewIndex)
- }
-
- @Test func stderrLogFilterSuppressesCommandOutputAfterToolError() {
- var filter = AppServerStderrLogFilter()
- let stderr = """
- \u{001B}[31m2026-06-08T09:20:00.000Z ERROR codex_core::tools::router: error=Exit code: 124\u{001B}[0m
- Wall time: 20 seconds
- Output:
- command timed out after 20000 milliseconds
- README.md | 1 +
- func expensiveDump() {}
- 2026-06-08T09:20:01.000Z ERROR codex_core::exec: next error
-
- """
-
- var events = filter.append(Data(stderr.utf8))
- events.append(contentsOf: filter.finish())
-
- #expect(events.map(\.level) == [.error, .error, .warning, .warning, .warning, .error])
- #expect(events.map(\.message) == [
- "2026-06-08T09:20:00.000Z ERROR codex_core::tools::router: error=Exit code: 124",
- "Wall time: 20 seconds",
- "command output omitted after tool error",
- "command timed out after 20000 milliseconds",
- "suppressed 2 command-output line(s)",
- "2026-06-08T09:20:01.000Z ERROR codex_core::exec: next error",
- ])
- }
-
- @Test func processTransportConfigurationUsesDedicatedCodexHome() throws {
- let configuration = AppServerProcessTransport.Configuration(
- environment: [
- "PATH": "/usr/bin",
- "HOME": "/tmp/review-home",
- "CODEX_SQLITE_HOME": "/tmp/main-codex-sqlite",
- ]
- )
-
- #expect(configuration.codexHomeURL.path == "/tmp/review-home/.codex_review")
- #expect(configuration.environment["CODEX_HOME"] == "/tmp/review-home/.codex_review")
- #expect(configuration.environment["CODEX_SQLITE_HOME"] == "/tmp/review-home/.codex_review/sqlite")
- }
-
- @Test func processTransportConfigurationUsesExplicitCodexHome() throws {
- let configuration = AppServerProcessTransport.Configuration(
- environment: [
- "PATH": "/usr/bin",
- "HOME": "/tmp/review-home",
- "CODEX_HOME": "/tmp/custom-codex-review",
- "CODEX_SQLITE_HOME": "/tmp/main-codex-sqlite",
- ]
- )
-
- #expect(configuration.codexHomeURL.path == "/tmp/custom-codex-review")
- #expect(configuration.environment["CODEX_HOME"] == "/tmp/custom-codex-review")
- #expect(configuration.environment["CODEX_SQLITE_HOME"] == "/tmp/custom-codex-review/sqlite")
- }
-
- @Test func processTransportScaffoldsDedicatedSqliteHome() throws {
- let directory = FileManager.default.temporaryDirectory
- .appendingPathComponent("codex-review-home-\(UUID().uuidString)", isDirectory: true)
- defer {
- try? FileManager.default.removeItem(at: directory)
- }
-
- try AppServerCodexHome.ensureScaffold(at: directory)
-
- #expect(FileManager.default.fileExists(atPath: directory.appending(path: "config.toml").path))
- #expect(FileManager.default.fileExists(atPath: directory.appending(path: "AGENTS.md").path))
- #expect(FileManager.default.fileExists(
- atPath: directory.appendingPathComponent("sqlite", isDirectory: true).path
- ))
- }
-
- @Test func processTransportCloseTerminatesSpawnedProcessGroup() async throws {
- let directory = FileManager.default.temporaryDirectory
- .appending(path: "codex-review-process-group-\(UUID().uuidString)")
- try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
- defer {
- try? FileManager.default.removeItem(at: directory)
- }
-
- let executable = directory.appending(path: "app-server-stub.sh")
- let childPIDFile = directory.appending(path: "child.pid")
- let readyFile = directory.appending(path: "ready")
- let script = """
- #!/bin/sh
- child_pid_file="$1"
- ready_file="$2"
- (
- while true; do sleep 1; done
- ) &
- echo $! > "$child_pid_file"
- touch "$ready_file"
- while true; do sleep 1; done
- """
- try Data(script.utf8).write(to: executable)
- try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)
-
- let transport = try AppServerProcessTransport(configuration: .init(
- executable: executable.path,
- arguments: [childPIDFile.path, readyFile.path],
- environment: [
- "HOME": directory.path,
- "PATH": "/bin:/usr/bin",
- ]
- ))
- let becameReady = await waitUntil(timeout: .seconds(2)) {
- FileManager.default.fileExists(atPath: readyFile.path)
- }
- #expect(becameReady)
-
- let childPIDText = try String(contentsOf: childPIDFile, encoding: .utf8)
- .trimmingCharacters(in: .whitespacesAndNewlines)
- let childPID = try #require(pid_t(childPIDText))
- defer {
- _ = Darwin.kill(childPID, SIGKILL)
- }
- #expect(Darwin.kill(childPID, 0) == 0)
-
- await transport.close()
-
- let childExited = await waitUntil(timeout: .seconds(2)) {
- Darwin.kill(childPID, 0) != 0 && errno == ESRCH
- }
- #expect(childExited)
-
- let notifications = await transport.notificationStream()
- var iterator = notifications.makeAsyncIterator()
- await #expect(throws: (any Error).self) {
- _ = try await iterator.next()
- }
- }
-
- @Test func processTransportProcessesChunkedStdoutBeforeEOF() async throws {
- let directory = FileManager.default.temporaryDirectory
- .appending(path: "codex-review-stdout-order-\(UUID().uuidString)")
- try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
- defer {
- try? FileManager.default.removeItem(at: directory)
- }
-
- let executable = directory.appending(path: "app-server-stub.sh")
- let script = """
- #!/bin/sh
- IFS= read -r request
- printf '{"id":1,"result":{"value":'
- sleep 0.05
- printf '"done"}}\\n'
- """
- try Data(script.utf8).write(to: executable)
- try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)
- let transport = try AppServerProcessTransport(configuration: .init(
- executable: executable.path,
- arguments: [],
- environment: [
- "HOME": directory.path,
- "PATH": "/bin:/usr/bin",
- ]
- ))
-
- let data = try await transport.send(JSONRPC.Request(
- id: 1,
- method: "test/request",
- params: Data("{}".utf8)
- ))
- await transport.close()
-
- let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
- #expect(object["value"] as? String == "done")
- }
-
- @Test func processTransportWritesCodexJSONRPCLiteMessages() async throws {
- let directory = FileManager.default.temporaryDirectory
- .appending(path: "codex-review-jsonrpc-lite-\(UUID().uuidString)")
- try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
- defer {
- try? FileManager.default.removeItem(at: directory)
- }
-
- let executable = directory.appending(path: "app-server-stub.sh")
- let requestFile = directory.appending(path: "request.json")
- let notificationFile = directory.appending(path: "notification.json")
- let script = """
- #!/bin/sh
- request_file="$1"
- notification_file="$2"
- IFS= read -r request
- printf '%s\\n' "$request" > "$request_file"
- printf '{"id":7,"result":{}}\\n'
- IFS= read -r notification
- printf '%s\\n' "$notification" > "$notification_file"
- """
- try Data(script.utf8).write(to: executable)
- try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)
- let transport = try AppServerProcessTransport(configuration: .init(
- executable: executable.path,
- arguments: [requestFile.path, notificationFile.path],
- environment: [
- "HOME": directory.path,
- "PATH": "/bin:/usr/bin",
- ]
- ))
-
- _ = try await transport.send(JSONRPC.Request(
- id: 7,
- method: "test/request",
- params: Data(#"{"value":true}"#.utf8)
- ))
- try await transport.notify(JSONRPC.Notification(
- method: "initialized",
- params: Data("{}".utf8)
- ))
- let notificationWritten = await waitUntil(timeout: .seconds(2)) {
- FileManager.default.fileExists(atPath: notificationFile.path)
- }
- await transport.close()
-
- #expect(notificationWritten)
- let request = try #require(JSONSerialization.jsonObject(
- with: Data(contentsOf: requestFile)
- ) as? [String: Any])
- let notification = try #require(JSONSerialization.jsonObject(
- with: Data(contentsOf: notificationFile)
- ) as? [String: Any])
- #expect(request["jsonrpc"] == nil)
- #expect(request["id"] as? Int == 7)
- #expect(request["method"] as? String == "test/request")
- #expect(notification["jsonrpc"] == nil)
- #expect(notification["method"] as? String == "initialized")
- }
-
- @Test func processTransportMapsNullJSONRPCResultToEmptyPayload() throws {
- let data = try AppServerProcessTransport.responsePayloadData(from: NSNull())
-
- #expect(String(decoding: data, as: UTF8.self) == "{}")
- #expect(try JSONDecoder().decode(EmptyResponse.self, from: data) == EmptyResponse())
- }
-
- @Test func appServerTurnErrorRequiresMessage() throws {
- let valid = Data(#"{"id":"turn-1","error":{"message":"cancelled"}}"#.utf8)
- let turn = try JSONDecoder().decode(AppServerAPI.Turn.Payload.self, from: valid)
- #expect(turn.error?.message == "cancelled")
-
- let missingMessage = Data(#"{"id":"turn-1","error":{}}"#.utf8)
- #expect(throws: (any Error).self) {
- try JSONDecoder().decode(AppServerAPI.Turn.Payload.self, from: missingMessage)
- }
- }
-
- @Test func processTransportBuildsErrorResponseForUnsupportedServerRequests() throws {
- let data = try AppServerProcessTransport.unsupportedServerRequestPayload(
- id: 42,
- method: "approval/request"
- )
- let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
- let error = try #require(object["error"] as? [String: Any])
-
- #expect(object["jsonrpc"] == nil)
- #expect(object["id"] as? Int == 42)
- #expect(error["code"] as? Int == -32601)
- #expect(error["message"] as? String == "Unsupported app-server request: approval/request")
- #expect(String(decoding: data, as: UTF8.self).hasSuffix("\n"))
- }
-
- @Test func sameThreadReviewRequestsDoNotOverlap() async throws {
- let transport = FakeJSONRPCTransport()
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1"), for: "review/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-2"), for: "review/start")
- let gate = AsyncGate()
- await transport.hold(method: "review/start", gate: gate)
- let client = AppServerClient(transport: transport)
-
- async let first: AppServerAPI.Review.Start.Response = client.send(AppServerAPI.Review.Start.Request(
- params: .init(threadID: "thread-1", target: .uncommittedChanges)
- ))
- async let second: AppServerAPI.Review.Start.Response = client.send(AppServerAPI.Review.Start.Request(
- params: .init(threadID: "thread-1", target: .uncommittedChanges)
- ))
- await transport.waitForRequestCount(1)
- await gate.open()
- _ = try await (first, second)
-
- #expect(await transport.maxActiveCount(for: "review/start") == 1)
- }
-
- @Test func differentThreadReviewRequestsCanOverlap() async throws {
- let transport = FakeJSONRPCTransport()
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1"), for: "review/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-2"), for: "review/start")
- let gate = AsyncGate()
- await transport.hold(method: "review/start", gate: gate)
- let client = AppServerClient(transport: transport)
-
- async let first: AppServerAPI.Review.Start.Response = client.send(AppServerAPI.Review.Start.Request(
- params: .init(threadID: "thread-1", target: .uncommittedChanges)
- ))
- async let second: AppServerAPI.Review.Start.Response = client.send(AppServerAPI.Review.Start.Request(
- params: .init(threadID: "thread-2", target: .uncommittedChanges)
- ))
- await transport.waitForRequestCount(2)
- await gate.open()
- _ = try await (first, second)
-
- #expect(await transport.maxActiveCount(for: "review/start") == 2)
- }
-
- @Test func sendRetriesAppServerOverloadWithFreshRequestID() async throws {
- let transport = FakeJSONRPCTransport()
- await transport.enqueueFailure(
- .responseError(code: -32001, message: "Server overloaded; retry later."),
- for: "test/request"
- )
- let client = AppServerClient(
- transport: transport,
- overloadRetryDelay: { _ in .milliseconds(100) },
- retrySleep: { _ in }
- )
-
- let response: EmptyResponse = try await client.send(
- method: "test/request",
- params: EmptyResponse(),
- responseType: EmptyResponse.self
- )
-
- #expect(response == EmptyResponse())
- let requests = await transport.recordedRequests()
- #expect(requests.map(\.method) == ["test/request", "test/request"])
- #expect(requests[0].id != requests[1].id)
- }
-
- @Test func sendDoesNotRetryNonOverloadAppServerErrors() async throws {
- let transport = FakeJSONRPCTransport()
- await transport.enqueueFailure(
- .responseError(code: -32602, message: "invalid target"),
- for: "test/request"
- )
- let client = AppServerClient(transport: transport)
-
- await #expect(throws: JSONRPC.Error.responseError(code: -32602, message: "invalid target")) {
- let _: EmptyResponse = try await client.send(
- method: "test/request",
- params: EmptyResponse(),
- responseType: EmptyResponse.self
- )
- }
- #expect(await transport.recordedRequests().map(\.method) == ["test/request"])
- }
-
- @Test func startupInterruptUsesEmptyTurnID() async throws {
- let transport = FakeJSONRPCTransport()
- try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
- let client = AppServerClient(transport: transport)
- let control = AppServerReviewControl(client: client)
-
- control.recordThreadStarted(threadID: "thread-1")
- let interruption = try await control.interrupt()
- #expect(interruption == .init(threadID: "thread-1", turnID: ""))
-
- let request = try #require(await transport.recordedRequests().last)
- #expect(request.method == "turn/interrupt")
- let params = try JSONDecoder().decode(AppServerAPI.Turn.Interrupt.Params.self, from: request.params)
- #expect(params.threadID == "thread-1")
- #expect(params.turnID == "")
- }
-
- @Test func runningInterruptUsesActualTurnID() async throws {
- let transport = FakeJSONRPCTransport()
- try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
- let client = AppServerClient(transport: transport)
- let control = AppServerReviewControl(client: client)
-
- control.recordReviewStarted(turnThreadID: "thread-1", turnID: "turn-1")
- let interruption = try await control.interrupt()
- #expect(interruption == .init(threadID: "thread-1", turnID: "turn-1"))
-
- let request = try #require(await transport.recordedRequests().last)
- let params = try JSONDecoder().decode(AppServerAPI.Turn.Interrupt.Params.self, from: request.params)
- #expect(params.turnID == "turn-1")
- }
-
- @Test func runningInterruptRetriesWithCurrentActiveTurnID() async throws {
- let transport = FakeJSONRPCTransport()
- await transport.enqueueFailure(
- .responseError(
- 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 client = AppServerClient(transport: transport)
- let control = AppServerReviewControl(client: client)
-
- control.recordReviewStarted(turnThreadID: "thread-1", turnID: "turn-old")
- let interruption = try await control.interrupt()
- #expect(interruption == .init(threadID: "thread-1", turnID: "turn-new"))
-
- let requests = await transport.recordedRequests()
- #expect(requests.map(\.method) == ["turn/interrupt", "turn/interrupt"])
- let first = try JSONDecoder().decode(AppServerAPI.Turn.Interrupt.Params.self, from: requests[0].params)
- let second = try JSONDecoder().decode(AppServerAPI.Turn.Interrupt.Params.self, from: requests[1].params)
- #expect(first.turnID == "turn-old")
- #expect(second.turnID == "turn-new")
- }
-
- @Test func initializeSendsInitializedNotificationOnce() async throws {
- let transport = FakeJSONRPCTransport()
- try await transport.enqueue(AppServerAPI.Initialize.Response(codexHome: "/tmp/codex"), for: "initialize")
- let client = AppServerClient(transport: transport)
-
- let response = try await client.initialize()
- _ = try await client.initialize()
-
- #expect(response.codexHome == "/tmp/codex")
- #expect(await transport.recordedRequests().map(\.method) == ["initialize"])
- #expect(await transport.recordedNotifications().map(\.method) == ["initialized"])
- let request = try #require(await transport.recordedRequests().first)
- let params = try #require(JSONSerialization.jsonObject(with: request.params) as? [String: Any])
- let clientInfo = try #require(params["clientInfo"] as? [String: Any])
- #expect(clientInfo["name"] as? String == "CodexReviewKit")
- #expect(clientInfo["version"] as? String == "2")
- let capabilities = try #require(params["capabilities"] as? [String: Any])
- #expect(capabilities["experimentalApi"] as? Bool == true)
- }
-
- @Test func concurrentInitializeCallsShareSingleHandshake() async throws {
- let transport = FakeJSONRPCTransport()
- try await transport.enqueue(AppServerAPI.Initialize.Response(codexHome: "/tmp/codex"), for: "initialize")
- let gate = AsyncGate()
- await transport.hold(method: "initialize", gate: gate)
- let client = AppServerClient(transport: transport)
-
- async let first = client.initialize()
- async let second = client.initialize()
- await transport.waitForRequestCount(1)
- await gate.open()
- let responses = try await (first, second)
-
- #expect(responses.0.codexHome == "/tmp/codex")
- #expect(responses.1.codexHome == "/tmp/codex")
- #expect(await transport.recordedRequests().map(\.method) == ["initialize"])
- #expect(await transport.recordedNotifications().map(\.method) == ["initialized"])
- }
-
- @Test func accountReadResponseDecodesChatGPTAccountAuthRequirement() throws {
- let data = Data("""
- {"account":{"type":"chatgpt","email":"review@example.com","planType":"pro"},"requiresOpenaiAuth":true}
- """.utf8)
- let response = try JSONDecoder().decode(AppServerAPI.Account.Read.Response.self, from: data)
-
- #expect(response.requiresOpenAIAuth)
- #expect(response.account?.id == .init("review@example.com"))
- #expect(response.account?.kind == .chatGPT)
- #expect(response.account?.label == "review@example.com")
- #expect(response.account?.planType == "pro")
- #expect(response.account?.capabilities.supportsRateLimitRefresh == true)
- }
-
- @Test func accountReadResponseNormalizesProviderAccountCapabilities() throws {
- let apiKeyData = Data("""
- {"account":{"type":"apiKey"},"requiresOpenaiAuth":false}
- """.utf8)
- let bedrockData = Data("""
- {"account":{"type":"amazonBedrock"},"requiresOpenaiAuth":false}
- """.utf8)
-
- let apiKeyResponse = try JSONDecoder().decode(AppServerAPI.Account.Read.Response.self, from: apiKeyData)
- let bedrockResponse = try JSONDecoder().decode(AppServerAPI.Account.Read.Response.self, from: bedrockData)
-
- #expect(apiKeyResponse.account?.id == .init("api-key"))
- #expect(apiKeyResponse.account?.kind == .apiKey)
- #expect(apiKeyResponse.account?.label == "API Key")
- #expect(apiKeyResponse.account?.capabilities.supportsRateLimitRefresh == false)
- #expect(bedrockResponse.account?.id == .init("amazon-bedrock"))
- #expect(bedrockResponse.account?.kind == .amazonBedrock)
- #expect(bedrockResponse.account?.label == "Amazon Bedrock")
- #expect(bedrockResponse.account?.capabilities.supportsRateLimitRefresh == false)
- }
-
- @Test func accountRateLimitsResponseResolvesCodexLimitWindows() throws {
- let data = Data("""
- {
- "rateLimits": {
- "limitId": "codex_bengalfox",
- "primary": {"usedPercent": 0, "windowDurationMins": 300, "resetsAt": 1779183121},
- "secondary": {"usedPercent": 0, "windowDurationMins": 10080, "resetsAt": 1779769921},
- "planType": "pro"
- },
- "rateLimitsByLimitId": {
- "codex": {
- "limitId": "codex",
- "primary": {"usedPercent": 0, "windowDurationMins": 300, "resetsAt": 1779176539},
- "secondary": {"usedPercent": 11, "windowDurationMins": 10080, "resetsAt": 1779571734},
- "planType": "pro"
- }
- }
- }
- """.utf8)
-
- let response = try JSONDecoder().decode(AppServerAPI.Account.RateLimits.Response.self, from: data)
-
- #expect(response.codexPlanType == "pro")
- #expect(response.codexRateLimitWindows.map(\.windowDurationMinutes) == [300, 10080])
- #expect(response.codexRateLimitWindows.map(\.usedPercent) == [0, 11])
- #expect(response.codexRateLimitWindows.first?.resetsAt == Date(timeIntervalSince1970: 1_779_176_539))
- }
-
- @Test func accountRateLimitsResponseFallsBackToCodexPrefixedTopLevelLimit() throws {
- let data = Data("""
- {
- "rateLimits": {
- "limitId": "codex_bengalfox",
- "primary": {"usedPercent": 17, "windowDurationMins": 300},
- "planType": "pro"
- }
- }
- """.utf8)
-
- let response = try JSONDecoder().decode(AppServerAPI.Account.RateLimits.Response.self, from: data)
-
- #expect(response.codexPlanType == "pro")
- #expect(response.codexRateLimitWindows.map(\.windowDurationMinutes) == [300])
- #expect(response.codexRateLimitWindows.map(\.usedPercent) == [17])
- }
-
- @Test func loginStartRequestsNativeAuthenticationWhenConfigured() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(
- AppServerAPI.Account.Login.Response.chatgpt(
- loginID: "login-1",
- authURL: "https://example.com/auth",
- nativeWebAuthentication: .init(callbackURLScheme: "lynnpd.CodexReviewMonitor.auth")
- ),
- for: "account/login/start"
- )
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let challenge = try await backend.startLogin(.init(
- nativeWebAuthenticationCallbackScheme: "lynnpd.CodexReviewMonitor.auth"
- ))
-
- #expect(challenge.id == "login-1")
- #expect(challenge.verificationURL == URL(string: "https://example.com/auth"))
- #expect(challenge.nativeWebAuthenticationCallbackScheme == "lynnpd.CodexReviewMonitor.auth")
- let request = try #require(await transport.recordedRequests().last)
- #expect(request.method == "account/login/start")
- let params = try JSONDecoder().decode(AppServerAPI.Account.Login.Params.self, from: request.params)
- #expect(params.nativeWebAuthentication?.callbackURLScheme == "lynnpd.CodexReviewMonitor.auth")
- }
-
- @Test func loginStartPreservesDeviceCodeUserCode() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(
- AppServerAPI.Account.Login.Response.chatgptDeviceCode(
- loginID: "login-1",
- verificationURL: "https://example.com/device",
- userCode: "ABCD-EFGH"
- ),
- for: "account/login/start"
- )
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let challenge = try await backend.startLogin(.init())
-
- #expect(challenge.id == "login-1")
- #expect(challenge.verificationURL == URL(string: "https://example.com/device"))
- #expect(challenge.userCode == "ABCD-EFGH")
- }
-
- @Test func loginStartRejectsInvalidAuthenticationURL() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(
- AppServerAPI.Account.Login.Response.chatgpt(
- loginID: "login-1",
- authURL: "file:///tmp/auth",
- nativeWebAuthentication: nil
- ),
- for: "account/login/start"
- )
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- await #expect(throws: (any Error).self) {
- _ = try await backend.startLogin(.init())
- }
- }
-
- @Test func settingsReadLoadsConfigAndModelCatalog() async throws {
- let modelList = Data("""
- {
- "data": [
- {
- "id": "gpt-5.5",
- "model": "gpt-5.5",
- "displayName": "GPT-5.5",
- "hidden": false,
- "supportedReasoningEfforts": [
- {"reasoningEffort": "medium", "description": "Balanced"},
- {"reasoningEffort": "xhigh", "description": "Extra high"}
- ],
- "defaultReasoningEffort": "xhigh",
- "serviceTiers": [{"id": "fast"}, {"id": "flex"}],
- "isDefault": true
- }
- ]
- }
- """.utf8)
- let transport = FakeJSONRPCTransport(responses: [
- "model/list": [modelList],
- ])
- try await enqueueInitialize(transport)
- try await transport.enqueue(
- AppServerAPI.Config.Read.Response(config: .init(
- model: "gpt-5",
- reviewModel: "gpt-5.5",
- modelReasoningEffort: "medium",
- serviceTier: "flex"
- )),
- for: "config/read"
- )
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let settings = try await backend.readSettings()
-
- #expect(settings.model == "gpt-5.5")
- #expect(settings.fallbackModel == "gpt-5")
- #expect(settings.reasoningEffort == "medium")
- #expect(settings.serviceTier == "flex")
- #expect(settings.models.map(\.model) == ["gpt-5.5"])
- #expect(settings.models.first?.supportedServiceTiers == [.fast, .flex])
- #expect(settings.models.first?.isDefault == true)
- }
-
- @Test func settingsReadUsesGlobalModelAsFallbackWhenReviewModelIsUnset() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(
- AppServerAPI.Config.Read.Response(config: .init(model: "gpt-5", reviewModel: nil)),
- for: "config/read"
- )
- try await transport.enqueue(
- AppServerAPI.Model.List.Response(data: [makeModelCatalogItem(model: "default-model", isDefault: true)]),
- for: "model/list"
- )
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let settings = try await backend.readSettings()
-
- #expect(settings.model == nil)
- #expect(settings.fallbackModel == "gpt-5")
- }
-
- @Test func settingsReadPagesThroughModelCatalog() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(
- AppServerAPI.Config.Read.Response(config: .init(model: nil, reviewModel: nil)),
- for: "config/read"
- )
- try await transport.enqueue(
- AppServerAPI.Model.List.Response(
- data: [makeModelCatalogItem(model: "first-model")],
- nextCursor: "page-2"
- ),
- for: "model/list"
- )
- try await transport.enqueue(
- AppServerAPI.Model.List.Response(
- data: [makeModelCatalogItem(model: "default-model", isDefault: true)]
- ),
- for: "model/list"
- )
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let settings = try await backend.readSettings()
-
- #expect(settings.fallbackModel == "default-model")
- #expect(settings.models.map(\.model) == ["first-model", "default-model"])
- let modelRequests = await transport.recordedRequests().filter { $0.method == "model/list" }
- #expect(modelRequests.count == 2)
- let firstParams = try JSONDecoder().decode(AppServerAPI.Model.List.Params.self, from: modelRequests[0].params)
- let secondParams = try JSONDecoder().decode(AppServerAPI.Model.List.Params.self, from: modelRequests[1].params)
- #expect(firstParams.cursor == nil)
- #expect(firstParams.includeHidden == true)
- #expect(secondParams.cursor == "page-2")
- #expect(secondParams.includeHidden == true)
- }
-
- @Test func reviewTargetEncodesAppServerTaggedShape() async throws {
- let transport = FakeJSONRPCTransport()
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1"), for: "review/start")
- let client = AppServerClient(transport: transport)
-
- let _: AppServerAPI.Review.Start.Response = try await client.send(AppServerAPI.Review.Start.Request(
- params: .init(threadID: "thread-1", target: .baseBranch("main"))
- ))
-
- let request = try #require(await transport.recordedRequests().last)
- let object = try #require(JSONSerialization.jsonObject(with: request.params) as? [String: Any])
- let target = try #require(object["target"] as? [String: Any])
- #expect(target["type"] as? String == "baseBranch")
- #expect(target["branch"] as? String == "main")
- #expect(target["_0"] == nil)
- #expect(object["delivery"] as? String == "inline")
- }
-
- @Test func backendStartsPersistentReviewThreads() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- _ = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
-
- let threadStart = try #require(await transport.recordedRequests().first { $0.method == "thread/start" })
- let params = try JSONDecoder().decode(AppServerAPI.Thread.Start.Params.self, from: threadStart.params)
- let object = try #require(JSONSerialization.jsonObject(with: threadStart.params) as? [String: Any])
- #expect(params.ephemeral == false)
- #expect(params.approvalPolicy == "never")
- #expect(params.permissions == .profileID(":danger-full-access"))
- #expect(params.sessionStartSource == .startup)
- #expect(params.threadSource == .user)
- #expect(params.sandbox == nil)
- #expect(object["permissions"] as? String == ":danger-full-access")
- #expect(object["sessionStartSource"] as? String == "startup")
- #expect(object["threadSource"] as? String == "user")
- #expect(object["sandbox"] == nil)
- }
-
- @Test func backendUsesLegacySandboxWhenProcessDoesNotSupportModernSessionSource() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(
- client: .init(transport: transport),
- threadStartPermissionStrategy: .legacySandbox
- )
-
- _ = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
-
- let threadStarts = await transport.recordedRequests().filter { $0.method == "thread/start" }
- #expect(threadStarts.count == 1)
- let request = try #require(threadStarts.first)
- let params = try #require(JSONSerialization.jsonObject(with: request.params) as? [String: Any])
- #expect(params["ephemeral"] as? Bool == false)
- #expect(params["sandbox"] as? String == "danger-full-access")
- #expect(params["permissions"] == nil)
- #expect(params["sessionStartSource"] as? String == "startup")
- #expect(params["threadSource"] as? String == "user")
- }
-
- @Test func backendRetriesThreadStartWithObjectPermissionsForInstalledCodex() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- await transport.enqueueFailure(
- .responseError(
- code: -32602,
- message: #"Invalid request: invalid type: string ":danger-full-access", expected internally tagged enum PermissionProfileSelectionParams"#
- ),
- for: "thread/start"
- )
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- _ = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
-
- let threadStarts = await transport.recordedRequests().filter { $0.method == "thread/start" }
- #expect(threadStarts.count == 2)
-
- let firstRequest = try #require(threadStarts.first)
- let secondRequest = try #require(threadStarts.last)
- let first = try #require(JSONSerialization.jsonObject(
- with: firstRequest.params
- ) as? [String: Any])
- let second = try #require(JSONSerialization.jsonObject(
- with: secondRequest.params
- ) as? [String: Any])
- let permissions = try #require(second["permissions"] as? [String: Any])
-
- #expect(first["permissions"] as? String == ":danger-full-access")
- #expect(first["sandbox"] == nil)
- #expect(first["sessionStartSource"] as? String == "startup")
- #expect(first["threadSource"] as? String == "user")
- #expect(permissions["type"] as? String == "profile")
- #expect(permissions["id"] as? String == ":danger-full-access")
- #expect(second["sandbox"] == nil)
- #expect(second["sessionStartSource"] as? String == "startup")
- #expect(second["threadSource"] as? String == "user")
- }
-
- @Test func backendFallsBackToLegacySandboxWhenInstalledCodexLacksDangerProfile() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- await transport.enqueueFailure(
- .responseError(
- code: -32602,
- message: #"Invalid request: invalid type: string ":danger-full-access", expected internally tagged enum PermissionProfileSelectionParams"#
- ),
- for: "thread/start"
- )
- await transport.enqueueFailure(
- .responseError(
- code: -32602,
- message: "failed to load configuration: default_permissions refers to unknown built-in profile `:danger-full-access`"
- ),
- for: "thread/start"
- )
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- _ = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
-
- let threadStarts = await transport.recordedRequests().filter { $0.method == "thread/start" }
- #expect(threadStarts.count == 3)
-
- let fallbackRequest = try #require(threadStarts.last)
- let fallback = try #require(JSONSerialization.jsonObject(
- with: fallbackRequest.params
- ) as? [String: Any])
- #expect(fallback["sandbox"] as? String == "danger-full-access")
- #expect(fallback["permissions"] == nil)
- #expect(fallback["sessionStartSource"] as? String == "startup")
- #expect(fallback["threadSource"] as? String == "user")
- }
-
- @Test func backendFallsBackToLegacySandboxWhenProfileIDPermissionsAreUnknown() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- await transport.enqueueFailure(
- .responseError(
- code: -32602,
- message: "failed to load configuration: default_permissions refers to unknown built-in profile `:danger-full-access`"
- ),
- for: "thread/start"
- )
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- _ = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
-
- let threadStarts = await transport.recordedRequests().filter { $0.method == "thread/start" }
- #expect(threadStarts.count == 2)
-
- let firstRequest = try #require(threadStarts.first)
- let fallbackRequest = try #require(threadStarts.last)
- let first = try #require(JSONSerialization.jsonObject(
- with: firstRequest.params
- ) as? [String: Any])
- let fallback = try #require(JSONSerialization.jsonObject(
- with: fallbackRequest.params
- ) as? [String: Any])
- #expect(first["permissions"] as? String == ":danger-full-access")
- #expect(first["sandbox"] == nil)
- #expect(fallback["sandbox"] as? String == "danger-full-access")
- #expect(fallback["permissions"] == nil)
- #expect(fallback["sessionStartSource"] as? String == "startup")
- #expect(fallback["threadSource"] as? String == "user")
- }
-
- @Test func backendAppliesRequestedReviewModelToThreadStartAndRunMetadata() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
- model: "gpt-5.5"
- ))
-
- let threadStart = try #require(await transport.recordedRequests().first { $0.method == "thread/start" })
- let params = try JSONDecoder().decode(AppServerAPI.Thread.Start.Params.self, from: threadStart.params)
- #expect(params.model == "gpt-5.5")
- #expect(run.model == "gpt-5.5")
- }
-
- @Test func appServerStartupResponsesDecodeNestedThreadAndTurnObjects() async throws {
- let threadStart = """
- {"thread":{"id":"thread-1"},"model":"gpt-5","modelProvider":"openai","serviceTier":null}
- """
- let reviewStart = """
- {"turn":{"id":"turn-1","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null},"reviewThreadId":"thread-1"}
- """
- let transport = FakeJSONRPCTransport(responses: [
- "initialize": [try JSONEncoder().encode(AppServerAPI.Initialize.Response())],
- "thread/start": [Data(threadStart.utf8)],
- "review/start": [Data(reviewStart.utf8)],
- ])
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
-
- #expect(run.threadID == "thread-1")
- #expect(run.turnID == "turn-1")
- #expect(run.model == "gpt-5")
- }
-
- @Test func threadUnsubscribeResponseDecodesStatus() throws {
- let data = Data(#"{"status":"unsubscribed"}"#.utf8)
- let response = try JSONDecoder().decode(AppServerAPI.Thread.Unsubscribe.Response.self, from: data)
-
- #expect(response.status == .unsubscribed)
- }
-
- @Test func backendKeepsParentThreadIDForDetachedReviewThread() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "parent-thread", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-old", reviewThreadID: "review-thread"), for: "review/start")
- try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- let events = await eventSequence(backend, run, includingDomainEvents: true)
-
- #expect(run.threadID == "parent-thread")
- #expect(run.reviewThreadID == "review-thread")
-
- try await transport.emitServerNotification(
- method: "turn/started",
- params: TestTurnNotification(
- threadID: "parent-thread",
- turn: .init(id: "turn-new"),
- reviewThreadID: "review-thread"
- )
- )
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .started(turnID: "turn-new", reviewThreadID: "review-thread", model: nil))
-
- try await backend.interruptReview(run, reason: .init())
-
- let request = try #require(await transport.recordedRequests().last)
- let params = try JSONDecoder().decode(AppServerAPI.Turn.Interrupt.Params.self, from: request.params)
- #expect(params.threadID == "parent-thread")
- #expect(params.turnID == "turn-new")
- }
-
- @Test func backendInterruptUsesDetachedReviewThreadBeforeStartedNotification() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "parent-thread", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-old", reviewThreadID: "review-thread"), for: "review/start")
- try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- try await backend.interruptReview(run, reason: .init(message: "Stop"))
-
- let request = try #require(await transport.recordedRequests().last)
- #expect(request.method == "turn/interrupt")
- let params = try JSONDecoder().decode(AppServerAPI.Turn.Interrupt.Params.self, from: request.params)
- #expect(params.threadID == "review-thread")
- #expect(params.turnID == "turn-old")
- }
-
- @Test func backendPreservesDetachedReviewThreadIDWhenReviewItemOmitsIt() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "parent-thread", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-old", reviewThreadID: "review-thread"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- let events = await eventSequence(backend, run, includingDomainEvents: true)
-
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "parent-thread",
- turnID: "turn-new",
- item: .init(type: "enteredReviewMode", id: "review-item-1", review: "current changes")
- )
- )
-
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .started(turnID: "turn-new", reviewThreadID: "review-thread", model: nil))
- #expect(try await iterator.next() == .logEntry(
- kind: .progress,
- text: "Reviewing current changes",
- groupID: "review-item-1",
- replacesGroup: true
- ))
- }
-
- @Test func backendEmitsDomainEventsBeforeCompatibleLegacyLogEntries() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "parent-thread", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "review-thread"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- var iterator = await eventSequence(backend, run, includingDomainEvents: true).makeAsyncIterator()
- let action = ReviewLogEntry.Metadata.CommandAction(
- kind: .read,
- command: "cat Package.swift",
- name: "Package.swift",
- path: "Package.swift"
- )
-
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "review-thread",
- turnID: "turn-1",
- item: .init(
- type: "commandExecution",
- id: "cmd-1",
- command: "swift test",
- commandActions: [
- .read(command: "cat Package.swift", name: "Package.swift", path: "Package.swift")
- ]
- )
- )
- )
-
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "review-thread", model: nil))
- guard case .domainEvents(let domainEvents, let suppressionCount) = try await iterator.next() else {
- Issue.record("expected domain events before legacy log entry")
- return
- }
- #expect(suppressionCount == 1)
- guard case .itemStarted(let seed) = try #require(domainEvents.first) else {
- Issue.record("expected itemStarted domain event")
- return
- }
- #expect(seed.id.rawValue == "cmd-1")
- guard case .command(let command) = seed.content else {
- Issue.record("expected command timeline content")
- return
- }
- #expect(command.command == "swift test")
- #expect(command.actions == [
- .init(kind: .read, command: "cat Package.swift", name: "Package.swift", path: "Package.swift")
- ])
-
- guard case .logEntry(let kind, let text, let groupID, let replacesGroup, let metadata) = try await iterator.next() else {
- Issue.record("expected compatible legacy log entry")
- return
- }
- #expect(kind == .command)
- #expect(text == "$ swift test")
- #expect(groupID == "cmd-1")
- #expect(replacesGroup)
- #expect(metadata?.sourceType == "commandExecution")
- #expect(metadata?.commandActions == [action])
- }
-
- @Test func backendKeepsAgentMessageUpdateOpenForFollowingDelta() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "parent-thread", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "review-thread"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- var iterator = await eventSequence(backend, run, includingDomainEvents: true).makeAsyncIterator()
-
- try await transport.emitServerNotification(
- method: "item/updated",
- params: TestItemNotification(
- threadID: "review-thread",
- turnID: "turn-1",
- item: .init(type: "agentMessage", id: "msg-1", text: "partial")
- )
- )
- try await transport.emitServerNotification(
- method: "item/agentMessage/delta",
- params: TestDeltaNotification(
- threadID: "review-thread",
- turnID: "turn-1",
- itemID: "msg-1",
- delta: " delta"
- )
- )
-
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "review-thread", model: nil))
- guard case .domainEvents(let updateEvents, let updateSuppressionCount) = try await iterator.next() else {
- Issue.record("expected agent message update domain event")
- return
- }
- #expect(updateSuppressionCount == 1)
- guard case .itemUpdated(let seed) = try #require(updateEvents.first) else {
- Issue.record("expected itemUpdated")
- return
- }
- #expect(seed.id.rawValue == "msg-1")
- #expect(seed.phase == .running)
- guard case .message(let message) = seed.content else {
- Issue.record("expected message content")
- return
- }
- #expect(message.text == "partial")
-
- guard case .logEntry(let kind, let text, let groupID, let replacesGroup, let metadata) = try await iterator.next() else {
- Issue.record("expected compatible agent message log entry")
- return
- }
- #expect(kind == .agentMessage)
- #expect(text == "partial")
- #expect(groupID == "msg-1")
- #expect(replacesGroup)
- #expect(metadata?.status == "inProgress")
-
- guard case .domainEvents(let deltaEvents, let deltaSuppressionCount) = try await iterator.next() else {
- Issue.record("expected agent message delta domain event")
- return
- }
- #expect(deltaSuppressionCount == 1)
- guard case .textDelta(let itemID, _, _, _, let delta) = try #require(deltaEvents.first) else {
- Issue.record("expected text delta")
- return
- }
- #expect(itemID.rawValue == "msg-1")
- #expect(delta == " delta")
- #expect(try await iterator.next() == .messageDelta(" delta", itemID: "msg-1"))
- }
-
- @Test func backendRoutesTurnAbortedAsCancellation() async throws {
- let run = CodexReviewBackendModel.Review.Run(threadID: "thread-1", turnID: "turn-1")
- let transport = FakeJSONRPCTransport()
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let events = await eventSequence(backend, run)
-
- try await transport.emitServerNotification(
- method: "turn/aborted",
- params: TestMessageNotification(threadID: "thread-1", turnID: "turn-1", message: "Stop")
- )
-
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .cancelled("Stop"))
- }
-
- @Test func backendFlushesPendingStreamedLogsBeforeFollowingDomainEvents() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- var iterator = await eventSequence(backend, run, includingDomainEvents: true).makeAsyncIterator()
-
- try await transport.emitServerNotification(
- method: "item/commandExecution/outputDelta",
- params: TestDeltaNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- itemID: "cmd-1",
- delta: "old output\n"
- )
- )
- try await transport.emitServerNotification(
- method: "item/agentMessage/delta",
- params: TestDeltaNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- itemID: "msg-1",
- delta: "new message"
- )
- )
-
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- guard case .domainEvents(let outputDomainEvents, let outputSuppressionCount) = try await iterator.next() else {
- Issue.record("expected command output domain event")
- return
- }
- #expect(outputSuppressionCount == 0)
- guard case .textDelta(let outputItemID, _, _, _, let outputDelta) = try #require(outputDomainEvents.first) else {
- Issue.record("expected command output text delta")
- return
- }
- #expect(outputItemID.rawValue == "cmd-1")
- #expect(outputDelta == "old output\n")
- #expect(try await iterator.next() == .suppressNextLegacyTimelineProjection)
- guard case .logEntry(let outputKind, let outputText, let outputGroupID, let outputReplacesGroup, _) = try await iterator.next() else {
- Issue.record("expected flushed pending command output before following domain event")
- return
- }
- #expect(outputKind == .commandOutput)
- #expect(outputText == "old output\n")
- #expect(outputGroupID == "cmd-1")
- #expect(outputReplacesGroup == false)
-
- guard case .domainEvents(let messageDomainEvents, let messageSuppressionCount) = try await iterator.next() else {
- Issue.record("expected following message domain event after pending log flush")
- return
- }
- #expect(messageSuppressionCount == 1)
- guard case .textDelta(let messageItemID, _, _, _, let messageDelta) = try #require(messageDomainEvents.first) else {
- Issue.record("expected message text delta")
- return
- }
- #expect(messageItemID.rawValue == "msg-1")
- #expect(messageDelta == "new message")
- #expect(try await iterator.next() == .messageDelta("new message", itemID: "msg-1"))
- }
-
- @Test func backendRoutesDirectOnlyItemUpdatedNotifications() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- var iterator = await eventSequence(backend, run, includingDomainEvents: true).makeAsyncIterator()
-
- try await transport.emitServerNotification(
- method: "item/updated",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "reasoning", id: "reasoning-1", status: "failed")
- )
- )
-
- guard case .domainEvents(let domainEvents, let suppressionCount) = try await iterator.next() else {
- Issue.record("expected direct-only domain event")
- return
- }
- #expect(suppressionCount == 0)
- guard case .itemUpdated(let seed) = try #require(domainEvents.first) else {
- Issue.record("expected itemUpdated domain event")
- return
- }
- #expect(seed.id.rawValue == "reasoning-1")
- #expect(seed.phase == .failed)
- }
-
- @Test func backendRetainsCompatibilityLogForTextBearingItemUpdatedNotifications() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- var iterator = await eventSequence(backend, run, includingDomainEvents: true).makeAsyncIterator()
-
- try await transport.emitServerNotification(
- method: "item/updated",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "plan", id: "plan-1", text: "- [inProgress] Inspect diff")
- )
- )
-
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- guard case .domainEvents(let domainEvents, let suppressionCount) = try await iterator.next() else {
- Issue.record("expected direct plan update")
- return
- }
- #expect(suppressionCount == 1)
- guard case .itemUpdated(let seed) = try #require(domainEvents.first) else {
- Issue.record("expected plan item update")
- return
- }
- #expect(seed.id.rawValue == "plan-1")
- guard case .plan(let plan) = seed.content else {
- Issue.record("expected plan content")
- return
- }
- #expect(plan.markdown == "- [inProgress] Inspect diff")
- #expect(try await iterator.next() == .logEntry(
- kind: .plan,
- text: "- [inProgress] Inspect diff",
- groupID: "plan-1",
- replacesGroup: true
- ))
- }
-
- @Test func backendMapsMCPToolProgressDomainEventAsProgress() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- var iterator = await eventSequence(backend, run, includingDomainEvents: true).makeAsyncIterator()
-
- try await transport.emitServerNotification(
- method: "item/mcpToolCall/progress",
- params: TestMessageNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- itemID: "tool-1",
- message: "Reading review job"
- )
- )
-
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- guard case .domainEvents(let domainEvents, let suppressionCount) = try await iterator.next() else {
- Issue.record("expected mcp progress domain event")
- return
- }
- #expect(suppressionCount == 1)
- guard case .itemUpdated(let seed) = try #require(domainEvents.first) else {
- Issue.record("expected progress item update")
- return
- }
- #expect(seed.id.rawValue == "tool-1:progress")
- guard case .toolCall(let toolCall) = seed.content else {
- Issue.record("expected tool call content")
- return
- }
- #expect(toolCall.progress == "Reading review job")
- #expect(toolCall.result == nil)
- }
-
- @Test func backendRoutesDiagnosticNotificationsToDomainTimeline() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- var iterator = await eventSequence(backend, run, includingDomainEvents: true).makeAsyncIterator()
-
- try await transport.emitServerNotification(
- method: "diagnostic",
- params: TestMessageNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- itemID: "diagnostic-1",
- message: "Sandbox degraded"
- )
- )
-
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- guard case .domainEvents(let domainEvents, let suppressionCount) = try await iterator.next() else {
- Issue.record("expected diagnostic domain event")
- return
- }
- #expect(suppressionCount == 1)
- guard case .itemUpdated(let seed) = try #require(domainEvents.first) else {
- Issue.record("expected diagnostic item update")
- return
- }
- #expect(seed.id.rawValue == "diagnostic-1")
- #expect(seed.kind.rawValue == "diagnostic")
- #expect(seed.family == .diagnostic)
- guard case .diagnostic(let diagnostic) = seed.content else {
- Issue.record("expected diagnostic content")
- return
- }
- #expect(diagnostic.message == "Sandbox degraded")
-
- guard case .logEntry(let kind, let text, let groupID, let replacesGroup, _) = try await iterator.next() else {
- Issue.record("expected compatible diagnostic log entry")
- return
- }
- #expect(kind == .diagnostic)
- #expect(text == "Sandbox degraded")
- #expect(groupID == "turn-1")
- #expect(replacesGroup == false)
- }
-
- @Test func backendClosesActiveCommandBeforeDirectOnlyProgressDomainEvents() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- var iterator = await eventSequence(backend, run, includingDomainEvents: true).makeAsyncIterator()
-
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "commandExecution", id: "cmd-1", command: "swift test")
- )
- )
- try await transport.emitServerNotification(
- method: "item/updated",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "reasoning", id: "reasoning-1", status: "completed")
- )
- )
-
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- _ = try await iterator.next()
- _ = try await iterator.next()
- guard case .logEntry(let kind, let text, let groupID, let replacesGroup, let metadata) = try await iterator.next() else {
- Issue.record("expected synthesized command completion before direct-only progress")
- return
- }
- #expect(kind == .command)
- #expect(text == "$ swift test")
- #expect(groupID == "cmd-1")
- #expect(replacesGroup)
- #expect(metadata?.commandStatus == "completed")
- guard case .domainEvents(let domainEvents, _) = try await iterator.next() else {
- Issue.record("expected direct-only progress domain event after command completion")
- return
- }
- guard case .itemUpdated(let seed) = try #require(domainEvents.first) else {
- Issue.record("expected reasoning update")
- return
- }
- #expect(seed.id.rawValue == "reasoning-1")
- }
-
- @Test func backendMarksTerminalErrorLogProjectionSuppressionAfterDirectDiagnostic() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- var iterator = await eventSequence(backend, run, includingDomainEvents: true).makeAsyncIterator()
-
- try await transport.emitServerNotification(
- method: "error",
- params: TestErrorNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- message: "App-server failed.",
- willRetry: false
- )
- )
-
- guard case .domainEvents(let domainEvents, let suppressionCount) = try await iterator.next() else {
- Issue.record("expected direct terminal error diagnostic")
- return
- }
- #expect(suppressionCount == 0)
- guard case .itemUpdated(let seed) = try #require(domainEvents.first) else {
- Issue.record("expected diagnostic item update")
- return
- }
- #expect(seed.kind.rawValue == "error")
- #expect(seed.family == .diagnostic)
- #expect(seed.phase == .failed)
- #expect(try await iterator.next() == .suppressNextTerminalFailureLogTimelineProjection)
- #expect(try await iterator.next() == .failed("App-server failed."))
- }
-
- @Test func backendKeepsUserMessagesOutOfDirectTimelineEvents() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- var iterator = await eventSequence(backend, run, includingDomainEvents: true).makeAsyncIterator()
-
- try await transport.emitServerNotification(
- method: "turn/started",
- params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "turn-1"))
- )
- try await transport.emitServerNotification(
- method: "item/completed",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "userMessage", id: "user-1", text: "Please review this diff")
- )
- )
- try await transport.emitServerNotification(
- method: "turn/completed",
- params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "turn-1", status: "completed"))
- )
-
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .completed(summary: "Succeeded.", result: nil))
- }
-
- @Test func backendPreservesBareAgentMessagesAsSeparateLegacyMessages() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- var iterator = await eventSequence(backend, run, includingDomainEvents: true).makeAsyncIterator()
-
- try await transport.emitServerNotification(
- method: "agent/message",
- params: TestMessageNotification(threadID: "thread-1", turnID: "turn-1", message: "First message")
- )
- try await transport.emitServerNotification(
- method: "agent/message",
- params: TestMessageNotification(threadID: "thread-1", turnID: "turn-1", message: "Second message")
- )
-
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .message("First message"))
- #expect(try await iterator.next() == .message("Second message"))
- }
-
- @Test func backendAllowsAgentMessageLifecycleWhenTopLevelItemIDIsPresent() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- var iterator = await eventSequence(backend, run, includingDomainEvents: true).makeAsyncIterator()
-
- try await transport.emitServerNotification(
- method: "turn/started",
- params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "turn-1"))
- )
- try await transport.emitServerNotification(
- method: "item/updated",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- itemID: "agent-1",
- item: .init(type: "agentMessage", id: "", text: "Scoped message")
- )
- )
-
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- guard case .domainEvents(let domainEvents, let suppressionCount) = try await iterator.next() else {
- Issue.record("expected top-level itemID agent message domain event")
- return
- }
- #expect(suppressionCount == 1)
- guard case .itemUpdated(let seed) = try #require(domainEvents.first) else {
- Issue.record("expected agent message update")
- return
- }
- #expect(seed.id.rawValue == "agent-1")
- guard case .message(let message) = seed.content else {
- Issue.record("expected message content")
- return
- }
- #expect(message.text == "Scoped message")
- }
-
- @Test func backendPreservesSyntheticDiagnosticsAsSeparateLegacyLogs() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- var iterator = await eventSequence(backend, run, includingDomainEvents: true).makeAsyncIterator()
-
- try await transport.emitServerNotification(
- method: "warning",
- params: TestMessageNotification(threadID: "thread-1", turnID: "turn-1", message: "First warning")
- )
- try await transport.emitServerNotification(
- method: "warning",
- params: TestMessageNotification(threadID: "thread-1", turnID: "turn-1", message: "Second warning")
- )
-
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .logEntry(
- kind: .diagnostic,
- text: "First warning",
- groupID: "turn-1",
- replacesGroup: false
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .diagnostic,
- text: "Second warning",
- groupID: "turn-1",
- replacesGroup: false
- ))
- }
-
- @Test func backendRetainsCompatibilityLogForTextBearingToolUpdates() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- var iterator = await eventSequence(backend, run, includingDomainEvents: true).makeAsyncIterator()
-
- try await transport.emitServerNotification(
- method: "item/updated",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(
- type: "mcpToolCall",
- id: "tool-1",
- status: "completed",
- server: "codex_review",
- tool: "review_read",
- result: "large result"
- )
- )
- )
-
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- guard case .domainEvents(let domainEvents, let suppressionCount) = try await iterator.next() else {
- Issue.record("expected direct tool update")
- return
- }
- #expect(suppressionCount == 1)
- guard case .itemUpdated(let seed) = try #require(domainEvents.first) else {
- Issue.record("expected tool item update")
- return
- }
- #expect(seed.id.rawValue == "tool-1")
- guard case .toolCall(let toolCall) = seed.content else {
- Issue.record("expected tool call content")
- return
- }
- #expect(toolCall.result == "large result")
- #expect(try await iterator.next() == .logEntry(
- kind: .toolCall,
- text: "large result",
- groupID: "tool-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "mcpToolCall",
- title: "codex_review.review_read",
- status: "completed",
- server: "codex_review",
- tool: "review_read",
- resultText: "large result"
- )
- ))
- }
-
- @Test func backendRoutesDetachedReviewThreadNotificationsToParentSession() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "parent-thread", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-old", reviewThreadID: "review-thread"), for: "review/start")
- try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- var iterator = await eventSequence(backend, run).makeAsyncIterator()
-
- try await transport.emitServerNotification(
- method: "turn/started",
- params: TestTurnNotification(
- threadID: "review-thread",
- turn: .init(id: "turn-new"),
- reviewThreadID: "review-thread"
- )
- )
- try await transport.emitServerNotification(
- method: "item/agentMessage/delta",
- params: TestDeltaNotification(
- threadID: "review-thread",
- turnID: "turn-new",
- itemID: "message-1",
- delta: "review text"
- )
- )
-
- #expect(try await iterator.next() == .started(turnID: "turn-new", reviewThreadID: "review-thread", model: nil))
- #expect(try await iterator.next() == .messageDelta("review text", itemID: "message-1"))
- #expect(await backend.reviewEventSessionMetricsForTesting(threadID: "review-thread")?.routed == 2)
-
- try await backend.interruptReview(run, reason: .init(message: "Stop"))
- let interruptRequest = try #require(await transport.recordedRequests().last)
- #expect(interruptRequest.method == "turn/interrupt")
- let interruptParams = try JSONDecoder().decode(AppServerAPI.Turn.Interrupt.Params.self, from: interruptRequest.params)
- #expect(interruptParams.threadID == "review-thread")
- #expect(interruptParams.turnID == "turn-new")
- }
-
- @Test func backendBuffersNotificationsBeforeAttemptMailboxRead() async throws {
- let run = CodexReviewBackendModel.Review.Run(threadID: "thread-1", turnID: "turn-1")
- let transport = FakeJSONRPCTransport()
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let events = await eventSequence(backend, run)
-
- try await transport.emitServerNotification(
- method: "item/agentMessage/delta",
- params: TestDeltaNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- itemID: "message-1",
- delta: "review text"
- )
- )
-
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .messageDelta("review text", itemID: "message-1"))
- }
-
- @Test func backendPreservesNotificationStreamErrorForLateEventStreamSubscriber() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- #expect(await transport.notificationStreamCount() == 1)
-
- await transport.finishNotificationStreams(throwing: JSONRPC.Error.closed)
- let routerStopped = await waitUntil {
- await backend.notificationRouterIsRunningForTesting() == false
- }
- #expect(routerStopped)
-
- var iterator = await eventSequence(backend, run).makeAsyncIterator()
- await #expect(throws: BackendReviewEventMailboxError.self) {
- _ = try await iterator.next()
- }
- await transport.close()
- }
-
- @Test func backendPreservesBufferedEventsBeforeNotificationStreamError() async throws {
- let transport = FakeJSONRPCTransport()
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let run = CodexReviewBackendModel.Review.Run(threadID: "thread-1", turnID: "turn-1")
- let events = await eventSequence(backend, run)
-
- try await transport.emitServerNotification(
- method: "item/agentMessage/delta",
- params: TestDeltaNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- itemID: "message-1",
- delta: "partial review"
- )
- )
- await transport.finishNotificationStreams(throwing: JSONRPC.Error.closed)
-
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .messageDelta("partial review", itemID: "message-1"))
- await #expect(throws: BackendReviewEventMailboxError.self) {
- _ = try await iterator.next()
- }
- }
-
- @Test func backendTracksSyntheticDetachedReviewThreadStartsForInterrupt() async throws {
- let transport = FakeJSONRPCTransport()
- try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let run = CodexReviewBackendModel.Review.Run(threadID: "parent-thread", reviewThreadID: "review-thread")
- var iterator = await eventSequence(backend, run).makeAsyncIterator()
-
- try await transport.emitServerNotification(
- method: "item/agentMessage/delta",
- params: TestDeltaNotification(
- threadID: "review-thread",
- turnID: "turn-new",
- itemID: "message-1",
- delta: "review text"
- )
- )
-
- #expect(try await iterator.next() == .started(turnID: "turn-new", reviewThreadID: "review-thread", model: nil))
- #expect(try await iterator.next() == .messageDelta("review text", itemID: "message-1"))
-
- try await backend.interruptReview(run, reason: .init(message: "Stop"))
- let interruptRequest = try #require(await transport.recordedRequests().last)
- #expect(interruptRequest.method == "turn/interrupt")
- let interruptParams = try JSONDecoder().decode(AppServerAPI.Turn.Interrupt.Params.self, from: interruptRequest.params)
- #expect(interruptParams.threadID == "review-thread")
- #expect(interruptParams.turnID == "turn-new")
- }
-
- @Test func backendBuffersDetachedReviewThreadNotificationsDuringReviewStart() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "parent-thread", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-old", reviewThreadID: "review-thread"), for: "review/start")
- try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
- let gate = AsyncGate()
- await transport.hold(method: "review/start", gate: gate)
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- async let started = backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- await transport.waitForRequestCount(3)
- try await transport.emitServerNotification(
- method: "turn/started",
- params: TestTurnNotification(
- threadID: "review-thread",
- turn: .init(id: "turn-new"),
- reviewThreadID: "review-thread"
- )
- )
- let bufferedDetachedNotification = await waitUntil {
- await backend.notificationRouterMetricsForTesting().buffered == 1
- }
- #expect(bufferedDetachedNotification)
-
- await gate.open()
- let run = try await started
- var iterator = await eventSequence(backend, run).makeAsyncIterator()
-
- #expect(try await iterator.next() == .started(turnID: "turn-new", reviewThreadID: "review-thread", model: nil))
- #expect(await backend.reviewEventSessionMetricsForTesting(threadID: "review-thread")?.routed == 1)
- try await backend.interruptReview(run, reason: .init(message: "Stop"))
- let interruptRequest = try #require(await transport.recordedRequests().last)
- #expect(interruptRequest.method == "turn/interrupt")
- let interruptParams = try JSONDecoder().decode(AppServerAPI.Turn.Interrupt.Params.self, from: interruptRequest.params)
- #expect(interruptParams.threadID == "review-thread")
- #expect(interruptParams.turnID == "turn-new")
- }
-
- @Test func backendBuffersParentThreadNotificationsDuringReviewStartUntilRunIsFinalized() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-response", reviewThreadID: "thread-1"), for: "review/start")
- try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
- let gate = AsyncGate()
- await transport.hold(method: "review/start", gate: gate)
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- async let started = backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- await transport.waitForRequestCount(3)
- try await transport.emitServerNotification(
- method: "turn/started",
- params: TestTurnNotification(
- threadID: "thread-1",
- turn: .init(id: "turn-new"),
- reviewThreadID: "thread-1"
- )
- )
- let bufferedParentNotification = await waitUntil {
- await backend.reviewEventSessionMetricsForTesting(threadID: "thread-1")?.buffered == 1
- }
- #expect(bufferedParentNotification)
-
- await gate.open()
- let run = try await started
- var iterator = await eventSequence(backend, run).makeAsyncIterator()
-
- #expect(try await iterator.next() == .started(turnID: "turn-new", reviewThreadID: "thread-1", model: nil))
- try await backend.interruptReview(run, reason: .init(message: "Stop"))
- let interruptRequest = try #require(await transport.recordedRequests().last)
- #expect(interruptRequest.method == "turn/interrupt")
- let interruptParams = try JSONDecoder().decode(AppServerAPI.Turn.Interrupt.Params.self, from: interruptRequest.params)
- #expect(interruptParams.threadID == "thread-1")
- #expect(interruptParams.turnID == "turn-new")
- }
-
- @Test func backendUsesSingleNotificationRouterForConcurrentReviews() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-2", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-2", reviewThreadID: "thread-2"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- async let first = backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project-1", target: .uncommittedChanges)
- ))
- async let second = backend.startReview(.init(
- jobID: "job-2",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project-2", target: .uncommittedChanges)
- ))
- _ = try await (first, second)
- await transport.waitForNotificationStreamCount(1)
-
- #expect(await transport.notificationStreamCount() == 1)
- }
-
- @Test func backendRoutesInterleavedNotificationsToMatchingReviewSessions() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-2", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-2", reviewThreadID: "thread-2"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let firstRun = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project-1", target: .uncommittedChanges)
- ))
- let secondRun = try await backend.startReview(.init(
- jobID: "job-2",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project-2", target: .uncommittedChanges)
- ))
- let firstEvents = await eventSequence(backend, firstRun)
- let secondEvents = await eventSequence(backend, secondRun)
- var firstIterator = firstEvents.makeAsyncIterator()
- var secondIterator = secondEvents.makeAsyncIterator()
-
- try await transport.emitServerNotification(
- method: "turn/started",
- params: TestTurnNotification(threadID: "thread-2", turn: .init(id: "turn-2"))
- )
- try await transport.emitServerNotification(
- method: "turn/started",
- params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "turn-1"))
- )
- try await transport.emitServerNotification(
- method: "item/agentMessage/delta",
- params: TestDeltaNotification(threadID: "thread-2", turnID: "turn-2", itemID: "msg-2", delta: "Second")
- )
- try await transport.emitServerNotification(
- method: "item/agentMessage/delta",
- params: TestDeltaNotification(threadID: "thread-1", turnID: "turn-1", itemID: "msg-1", delta: "First")
- )
-
- #expect(try await firstIterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- #expect(try await firstIterator.next() == .messageDelta("First", itemID: "msg-1"))
- #expect(try await secondIterator.next() == .started(turnID: "turn-2", reviewThreadID: "thread-2", model: nil))
- #expect(try await secondIterator.next() == .messageDelta("Second", itemID: "msg-2"))
- }
-
- @Test func backendBroadcastsGlobalDiagnosticsToActiveReviewSessions() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-2", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-2", reviewThreadID: "thread-2"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let firstRun = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project-1", target: .uncommittedChanges)
- ))
- let secondRun = try await backend.startReview(.init(
- jobID: "job-2",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project-2", target: .uncommittedChanges)
- ))
- var firstIterator = await eventSequence(backend, firstRun).makeAsyncIterator()
- var secondIterator = await eventSequence(backend, secondRun).makeAsyncIterator()
-
- try await transport.emitServerNotification(
- method: "warning",
- params: TestGlobalMessageNotification(message: "Global warning")
- )
-
- let expected = CodexReviewBackendModel.Review.Event.logEntry(
- kind: .diagnostic,
- text: "Global warning",
- groupID: nil,
- replacesGroup: false
- )
- #expect(try await firstIterator.next() == expected)
- #expect(try await secondIterator.next() == expected)
- #expect(await backend.notificationRouterMetricsForTesting().decoded == 1)
- #expect(await backend.notificationRouterMetricsForTesting().routed == 2)
- }
-
- @Test func backendBroadcastsThreadlessErrorsToActiveReviewSessions() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-2", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-2", reviewThreadID: "thread-2"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let firstRun = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project-1", target: .uncommittedChanges)
- ))
- let secondRun = try await backend.startReview(.init(
- jobID: "job-2",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project-2", target: .uncommittedChanges)
- ))
- var firstIterator = await eventSequence(backend, firstRun).makeAsyncIterator()
- var secondIterator = await eventSequence(backend, secondRun).makeAsyncIterator()
-
- try await transport.emitServerNotification(
- method: "error",
- params: TestErrorNotification(message: "App-server failed.", willRetry: false)
- )
-
- #expect(try await firstIterator.next() == .failed("App-server failed."))
- #expect(try await secondIterator.next() == .failed("App-server failed."))
- #expect(await backend.notificationRouterMetricsForTesting().decoded == 1)
- #expect(await backend.notificationRouterMetricsForTesting().routed == 2)
- }
-
- @Test func backendBuffersTerminalNotificationEmittedDuringReviewStart() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- let gate = AsyncGate()
- await transport.hold(method: "review/start", gate: gate)
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- async let started = backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- await transport.waitForRequestCount(3)
- try await transport.emitServerNotification(
- method: "turn/completed",
- params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "turn-1", status: "completed"))
- )
- await gate.open()
- let run = try await started
-
- var iterator = await eventSequence(backend, run).makeAsyncIterator()
- #expect(try await iterator.next() == .completed(summary: "Succeeded.", result: nil))
- }
-
- @Test func backendBuffersCancellationBeforeEventStreamRegistration() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
-
- try await backend.interruptReview(run, reason: .init(message: "Stop"))
-
- var iterator = await eventSequence(backend, run).makeAsyncIterator()
- #expect(try await iterator.next() == .cancelled("Stop"))
- #expect(try await iterator.next() == nil)
- }
-
- @Test func backendRecoverReviewRollsBackAndRestartsSameThread() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
- try await transport.enqueue(EmptyResponse(), for: "thread/rollback")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-2", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let run = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-1",
- reviewThreadID: "thread-1",
- model: "gpt-5"
- )
-
- let recovered = try await backend.resumeReviewRecovery(
- run,
- request: .init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main")),
- model: "gpt-5"
- ),
- reason: .init(message: "Network unavailable; waiting to reconnect.")
- )
-
- #expect(recovered.threadID == "thread-1")
- #expect(recovered.turnID == "turn-2")
- let requests = await transport.recordedRequests()
- #expect(requests.map(\.method) == [
- "initialize",
- "turn/interrupt",
- "thread/rollback",
- "review/start",
- ])
- let interrupt = try #require(requests.first { $0.method == "turn/interrupt" })
- let interruptParams = try JSONDecoder().decode(AppServerAPI.Turn.Interrupt.Params.self, from: interrupt.params)
- #expect(interruptParams.threadID == "thread-1")
- #expect(interruptParams.turnID == "turn-1")
- let rollback = try #require(requests.first { $0.method == "thread/rollback" })
- let rollbackParams = try JSONDecoder().decode(AppServerAPI.Thread.Rollback.Params.self, from: rollback.params)
- #expect(rollbackParams.threadID == "thread-1")
- #expect(rollbackParams.numTurns == 1)
- let restart = try #require(requests.first { $0.method == "review/start" })
- let restartParams = try JSONDecoder().decode(AppServerAPI.Review.Start.Params.self, from: restart.params)
- #expect(restartParams.threadID == "thread-1")
- #expect(restartParams.target == .baseBranch("main"))
- }
-
- @Test func backendRecoverReviewUsesDetachedReviewThreadBeforeStartedNotification() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "parent-thread", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-old", reviewThreadID: "review-thread"), for: "review/start")
- try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
- try await transport.enqueue(EmptyResponse(), for: "thread/rollback")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-2", reviewThreadID: "review-thread"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
-
- let recovered = try await backend.resumeReviewRecovery(
- run,
- request: .init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main")),
- model: "gpt-5"
- ),
- reason: .init(message: "Network unavailable; waiting to reconnect.")
- )
-
- #expect(recovered.threadID == "parent-thread")
- #expect(recovered.turnID == "turn-2")
- #expect(recovered.reviewThreadID == "review-thread")
- let requests = await transport.recordedRequests()
- let interrupt = try #require(requests.first { $0.method == "turn/interrupt" })
- let interruptParams = try JSONDecoder().decode(AppServerAPI.Turn.Interrupt.Params.self, from: interrupt.params)
- #expect(interruptParams.threadID == "review-thread")
- #expect(interruptParams.turnID == "turn-old")
- let rollback = try #require(requests.first { $0.method == "thread/rollback" })
- let rollbackParams = try JSONDecoder().decode(AppServerAPI.Thread.Rollback.Params.self, from: rollback.params)
- #expect(rollbackParams.threadID == "review-thread")
- #expect(rollbackParams.numTurns == 1)
- let restart = try #require(requests.last { $0.method == "review/start" })
- let restartParams = try JSONDecoder().decode(AppServerAPI.Review.Start.Params.self, from: restart.params)
- #expect(restartParams.threadID == "parent-thread")
- #expect(restartParams.target == .baseBranch("main"))
- }
-
- @Test func backendRecoverReviewRollsBackInterruptedDetachedReviewThread() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "parent-thread", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-old", reviewThreadID: "review-thread"), for: "review/start")
- try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
- try await transport.enqueue(EmptyResponse(), for: "thread/rollback")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-2", reviewThreadID: "review-thread"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let startedRun = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- var iterator = await eventSequence(backend, startedRun).makeAsyncIterator()
- try await transport.emitServerNotification(
- method: "turn/started",
- params: TestTurnNotification(
- threadID: "review-thread",
- turn: .init(id: "turn-new"),
- reviewThreadID: "review-thread"
- )
- )
- #expect(try await iterator.next() == .started(
- turnID: "turn-new",
- reviewThreadID: "review-thread",
- model: nil
- ))
- let currentRun = CodexReviewBackendModel.Review.Run(
- threadID: "parent-thread",
- turnID: "turn-new",
- reviewThreadID: "review-thread",
- model: "gpt-5"
- )
- let reason = CodexReviewBackendModel.CancellationReason(message: "Network unavailable; waiting to reconnect.")
-
- let token = try await backend.beginReviewRecovery(currentRun, reason: reason)
- let recovered = try await backend.resumeReviewRecovery(
- token,
- request: .init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main")),
- model: "gpt-5"
- )
- )
-
- #expect(recovered.threadID == "parent-thread")
- #expect(recovered.turnID == "turn-2")
- #expect(recovered.reviewThreadID == "review-thread")
- let requests = await transport.recordedRequests()
- let interruptParams = try requests
- .filter { $0.method == "turn/interrupt" }
- .map { try JSONDecoder().decode(AppServerAPI.Turn.Interrupt.Params.self, from: $0.params) }
- #expect(interruptParams == [
- .init(threadID: "review-thread", turnID: "turn-new"),
- ])
- let rollback = try #require(requests.first { $0.method == "thread/rollback" })
- let rollbackParams = try JSONDecoder().decode(AppServerAPI.Thread.Rollback.Params.self, from: rollback.params)
- #expect(rollbackParams.threadID == "review-thread")
- #expect(rollbackParams.numTurns == 1)
- let restart = try #require(requests.last { $0.method == "review/start" })
- let restartParams = try JSONDecoder().decode(AppServerAPI.Review.Start.Params.self, from: restart.params)
- #expect(restartParams.threadID == "parent-thread")
- #expect(restartParams.target == .baseBranch("main"))
- }
-
- @Test func backendRecoverReviewDefaultsMissingReviewThreadToActiveThread() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
- try await transport.enqueue(EmptyResponse(), for: "thread/rollback")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-2"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let run = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-1",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
-
- let recovered = try await backend.resumeReviewRecovery(
- run,
- request: .init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main")),
- model: "gpt-5"
- ),
- reason: .init(message: "Network unavailable; waiting to reconnect.")
- )
-
- #expect(recovered.threadID == "thread-1")
- #expect(recovered.turnID == "turn-2")
- #expect(recovered.reviewThreadID == "thread-1")
- }
-
- @Test func backendSuppressesRecoveryInterruptTerminalEvent() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
- try await transport.enqueue(EmptyResponse(), for: "thread/rollback")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-2", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let run = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-1",
- reviewThreadID: "thread-1",
- model: "gpt-5"
- )
- let initialEvents = await eventSequence(backend, run)
- defer { withExtendedLifetime(initialEvents) {} }
-
- let recoveredRun = try await backend.resumeReviewRecovery(
- run,
- request: .init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main")),
- model: "gpt-5"
- ),
- reason: .init(message: "Network unavailable; waiting to reconnect.")
- )
- try await transport.emitServerNotification(
- method: "turn/completed",
- params: TestTurnNotification(
- threadID: "thread-1",
- turn: .init(id: "turn-1", status: "interrupted", error: .init(message: "Network unavailable"))
- )
- )
- try await transport.emitServerNotification(
- method: "turn/started",
- params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "turn-2"))
- )
-
- let recoveredEvents = await eventSequence(backend, recoveredRun)
- var iterator = recoveredEvents.makeAsyncIterator()
- #expect(try await iterator.next() == .started(
- turnID: "turn-2",
- reviewThreadID: "thread-1",
- model: nil
- ))
- }
-
- @Test func backendRecoverReviewDoesNotReinterruptPreviouslyInterruptedTurn() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
- try await transport.enqueue(EmptyResponse(), for: "thread/rollback")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-2", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let run = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-1",
- reviewThreadID: "thread-1",
- model: "gpt-5"
- )
- let initialEvents = await eventSequence(backend, run)
- defer { withExtendedLifetime(initialEvents) {} }
-
- let token = try await backend.beginReviewRecovery(
- run,
- reason: .init(message: "Network unavailable; waiting to reconnect.")
- )
- try await transport.emitServerNotification(
- method: "turn/completed",
- params: TestTurnNotification(
- threadID: "thread-1",
- turn: .init(id: "turn-1", status: "interrupted", error: .init(message: "Network unavailable"))
- )
- )
- let ignoredInterruptedTurn = await waitUntil {
- await backend.notificationRouterMetricsForTesting().ignored == 1
- }
- #expect(ignoredInterruptedTurn)
-
- let recovered = try await backend.resumeReviewRecovery(
- token,
- request: .init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main")),
- model: "gpt-5"
- )
- )
-
- #expect(recovered.turnID == "turn-2")
- let requests = await transport.recordedRequests()
- #expect(requests.map(\.method) == [
- "initialize",
- "turn/interrupt",
- "thread/rollback",
- "review/start",
- ])
- let recoveredEvents = await eventSequence(backend, recovered)
- var iterator = recoveredEvents.makeAsyncIterator()
- try await transport.emitServerNotification(
- method: "turn/started",
- params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "turn-2"))
- )
- #expect(try await iterator.next() == .started(
- turnID: "turn-2",
- reviewThreadID: "thread-1",
- model: nil
- ))
- }
-
- @Test func backendCancelAfterRecoveryInterruptDoesNotReinterruptStoppedTurn() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let run = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-1",
- reviewThreadID: "thread-1",
- model: "gpt-5"
- )
- let events = await eventSequence(backend, run)
- var iterator = events.makeAsyncIterator()
-
- _ = try await backend.beginReviewRecovery(
- run,
- reason: .init(message: "Network unavailable; waiting to reconnect.")
- )
- try await transport.emitServerNotification(
- method: "turn/completed",
- params: TestTurnNotification(
- threadID: "thread-1",
- turn: .init(id: "turn-1", status: "interrupted", error: .init(message: "Network unavailable"))
- )
- )
- let ignoredInterruptedTurn = await waitUntil {
- await backend.notificationRouterMetricsForTesting().ignored == 1
- }
- #expect(ignoredInterruptedTurn)
-
- try await backend.interruptReview(run, reason: .init(message: "Stop"))
-
- #expect(try await iterator.next() == nil)
- let interruptRequests = await transport.recordedRequests().filter { $0.method == "turn/interrupt" }
- #expect(interruptRequests.count == 1)
- }
-
- @Test func backendIgnoresCompletedAbandonedRecoveryTurn() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let run = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-1",
- reviewThreadID: "thread-1",
- model: "gpt-5"
- )
- let events = await eventSequence(backend, run)
- var iterator = events.makeAsyncIterator()
-
- _ = try await backend.beginReviewRecovery(
- run,
- reason: .init(message: "Network unavailable; waiting to reconnect.")
- )
- try await transport.emitServerNotification(
- method: "turn/completed",
- params: TestTurnNotification(
- threadID: "thread-1",
- turn: .init(id: "turn-1", status: "completed"),
- result: "finished review"
- )
- )
-
- #expect(try await iterator.next() == nil)
- let ignoredCompletedTurn = await waitUntil {
- await backend.notificationRouterMetricsForTesting().ignored == 1
- }
- #expect(ignoredCompletedTurn)
- }
-
- @Test func backendCancelDuringFailingRecoveryInterruptStillInterruptsTurn() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- await transport.enqueueFailure(
- .responseError(code: -32000, message: "network unavailable"),
- for: "turn/interrupt"
- )
- try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
- let interruptGate = AsyncGate()
- await transport.hold(method: "turn/interrupt", gate: interruptGate)
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let run = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-1",
- reviewThreadID: "thread-1",
- model: "gpt-5"
- )
- let events = await eventSequence(backend, run)
- var iterator = events.makeAsyncIterator()
-
- async let recovery: CodexReviewBackendModel.Review.RecoveryToken = backend.beginReviewRecovery(
- run,
- reason: .init(message: "Network unavailable; waiting to reconnect.")
- )
- let recoveryInterruptRequested = await waitUntil {
- await transport.recordedRequests().filter { $0.method == "turn/interrupt" }.count == 1
- }
- #expect(recoveryInterruptRequested)
-
- async let cancellation: Void = backend.interruptReview(run, reason: .init(message: "Stop"))
- let cancellationInterruptRequested = await waitUntil {
- await transport.recordedRequests().filter { $0.method == "turn/interrupt" }.count == 2
- }
- #expect(cancellationInterruptRequested)
-
- await interruptGate.open()
- do {
- _ = try await recovery
- Issue.record("Expected recovery interrupt to fail.")
- } catch {}
- try await cancellation
-
- #expect(try await iterator.next() == .cancelled("Stop"))
- #expect(try await iterator.next() == nil)
- let interruptRequests = await transport.recordedRequests().filter { $0.method == "turn/interrupt" }
- #expect(interruptRequests.count == 2)
- }
-
- @Test func backendSuppressesRecoveryInterruptRetriedToActiveTurn() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- await transport.enqueueFailure(
- .responseError(
- code: -32602,
- message: "expected active turn id turn-old but found turn-active"
- ),
- for: "turn/interrupt"
- )
- try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let run = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-old",
- reviewThreadID: "thread-1",
- model: "gpt-5"
- )
- let events = await eventSequence(backend, run)
-
- _ = try await backend.beginReviewRecovery(
- run,
- reason: .init(message: "Network unavailable; waiting to reconnect.")
- )
- let requests = await transport.recordedRequests()
- let interruptRequests = requests.filter { $0.method == "turn/interrupt" }
- let interruptTurnIDs = try interruptRequests.map { request in
- try JSONDecoder().decode(AppServerAPI.Turn.Interrupt.Params.self, from: request.params).turnID
- }
- #expect(interruptTurnIDs == ["turn-old", "turn-active"])
-
- try await transport.emitServerNotification(
- method: "turn/completed",
- params: TestTurnNotification(
- threadID: "thread-1",
- turn: .init(id: "turn-active", status: "interrupted", error: .init(message: "Network unavailable"))
- )
- )
- let ignoredInterruptedTurn = await waitUntil {
- await backend.notificationRouterMetricsForTesting().ignored == 1
- }
- #expect(ignoredInterruptedTurn)
- _ = events
- }
-
- @Test func backendSuppressesActiveTurnTerminalWhileRecoveryRetryInterruptIsInFlight() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- await transport.enqueueFailure(
- .responseError(
- code: -32602,
- message: "expected active turn id turn-old but found turn-active"
- ),
- for: "turn/interrupt"
- )
- try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
- let firstInterruptGate = AsyncGate()
- await firstInterruptGate.open()
- let retryInterruptGate = AsyncGate()
- await transport.holdNext(method: "turn/interrupt", gate: firstInterruptGate)
- await transport.holdNext(method: "turn/interrupt", gate: retryInterruptGate)
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let run = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-old",
- reviewThreadID: "thread-1",
- model: "gpt-5"
- )
- let events = await eventSequence(backend, run)
-
- async let recovery: CodexReviewBackendModel.Review.RecoveryToken = backend.beginReviewRecovery(
- run,
- reason: .init(message: "Network unavailable; waiting to reconnect.")
- )
- let retryInterruptRequested = await waitUntil {
- await transport.recordedRequests().filter { $0.method == "turn/interrupt" }.count == 2
- }
- #expect(retryInterruptRequested)
-
- try await transport.emitServerNotification(
- method: "turn/completed",
- params: TestTurnNotification(
- threadID: "thread-1",
- turn: .init(id: "turn-active", status: "interrupted", error: .init(message: "Network unavailable"))
- )
- )
- let ignoredTerminal = await waitUntil {
- await backend.notificationRouterMetricsForTesting().ignored == 1
- }
- #expect(ignoredTerminal)
-
- await retryInterruptGate.open()
- _ = try await recovery
- _ = events
- }
-
- @Test func backendRecoveryBuffersFastTerminalNotificationUntilRecoveredRunIsTracked() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
- try await transport.enqueue(EmptyResponse(), for: "thread/rollback")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-2", reviewThreadID: "thread-1"), for: "review/start")
- let reviewStartGate = AsyncGate()
- await transport.hold(method: "review/start", gate: reviewStartGate)
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let run = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-1",
- reviewThreadID: "thread-1",
- model: "gpt-5"
- )
- let initialEvents = await eventSequence(backend, run)
- defer { withExtendedLifetime(initialEvents) {} }
-
- async let recovered = backend.resumeReviewRecovery(
- run,
- request: CodexReviewBackendModel.Review.Start(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main")),
- model: "gpt-5"
- ),
- reason: .init(message: "Network unavailable; waiting to reconnect.")
- )
- await transport.waitForRequestCount(4)
- try await transport.emitServerNotification(
- method: "turn/completed",
- params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "turn-2", status: "completed"))
- )
- await reviewStartGate.open()
- let recoveredRun = try await recovered
-
- #expect(recoveredRun.turnID == "turn-2")
- let recoveredEvents = await eventSequence(backend, recoveredRun)
- var iterator = recoveredEvents.makeAsyncIterator()
- #expect(try await iterator.next() == .completed(summary: "Succeeded.", result: nil))
- }
-
- @Test func backendIgnoresStaleTerminalWhileRecoveryInterruptIsInFlight() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
- try await transport.enqueue(EmptyResponse(), for: "thread/rollback")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-2", reviewThreadID: "thread-1"), for: "review/start")
- let interruptGate = AsyncGate()
- await transport.holdNext(method: "turn/interrupt", gate: interruptGate)
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let run = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-1",
- reviewThreadID: "thread-1",
- model: "gpt-5"
- )
- let initialEvents = await eventSequence(backend, run)
- defer { withExtendedLifetime(initialEvents) {} }
-
- async let recovered = backend.resumeReviewRecovery(
- run,
- request: CodexReviewBackendModel.Review.Start(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main")),
- model: "gpt-5"
- ),
- reason: .init(message: "Network unavailable; waiting to reconnect.")
- )
- let interruptRequested = await waitUntil {
- await transport.recordedRequests().contains { $0.method == "turn/interrupt" }
- }
- #expect(interruptRequested)
-
- try await transport.emitServerNotification(
- method: "turn/completed",
- params: TestTurnNotification(
- threadID: "thread-1",
- turn: .init(id: "turn-1", status: "failed", error: .init(message: "Old turn failed"))
- )
- )
- let ignoredStaleTerminal = await waitUntil {
- await backend.notificationRouterMetricsForTesting().ignored == 1
- }
- #expect(ignoredStaleTerminal)
-
- await interruptGate.open()
- let recoveredRun = try await recovered
- #expect(recoveredRun.turnID == "turn-2")
- let recoveredEvents = await eventSequence(backend, recoveredRun)
- var iterator = recoveredEvents.makeAsyncIterator()
-
- try await transport.emitServerNotification(
- method: "turn/started",
- params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "turn-2"))
- )
- #expect(try await iterator.next() == .started(
- turnID: "turn-2",
- reviewThreadID: "thread-1",
- model: nil
- ))
-
- try await transport.emitServerNotification(
- method: "turn/completed",
- params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "turn-2", status: "completed"))
- )
- #expect(try await iterator.next() == .completed(summary: "Succeeded.", result: nil))
- }
-
- @Test func backendIgnoresStaleInterruptedTurnNotificationsWhileRollbackIsInFlight() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
- try await transport.enqueue(EmptyResponse(), for: "thread/rollback")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-2", reviewThreadID: "thread-1"), for: "review/start")
- let rollbackGate = AsyncGate()
- await transport.holdNext(method: "thread/rollback", gate: rollbackGate)
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let run = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-1",
- reviewThreadID: "thread-1",
- model: "gpt-5"
- )
- let initialEvents = await eventSequence(backend, run)
- defer { withExtendedLifetime(initialEvents) {} }
-
- async let recovered = backend.resumeReviewRecovery(
- run,
- request: CodexReviewBackendModel.Review.Start(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main")),
- model: "gpt-5"
- ),
- reason: .init(message: "Network unavailable; waiting to reconnect.")
- )
- let rollbackRequested = await waitUntil {
- await transport.recordedRequests().contains { $0.method == "thread/rollback" }
- }
- #expect(rollbackRequested)
-
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "commandExecution", id: "cmd-1", command: "swift test")
- )
- )
- let ignoredStaleNotification = await waitUntil {
- await backend.notificationRouterMetricsForTesting().ignored == 1
- }
- #expect(ignoredStaleNotification)
-
- await rollbackGate.open()
- let recoveredRun = try await recovered
- #expect(recoveredRun.turnID == "turn-2")
- let recoveredEvents = await eventSequence(backend, recoveredRun)
- var iterator = recoveredEvents.makeAsyncIterator()
-
- try await transport.emitServerNotification(
- method: "turn/started",
- params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "turn-2"))
- )
- #expect(try await iterator.next() == .started(
- turnID: "turn-2",
- reviewThreadID: "thread-1",
- model: nil
- ))
- }
-
- @Test func backendSuppressesRetriedActiveTurnNotificationsDuringRollback() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- await transport.enqueueFailure(
- .responseError(
- code: -32602,
- message: "expected active turn id turn-old but found turn-active"
- ),
- for: "turn/interrupt"
- )
- try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
- try await transport.enqueue(EmptyResponse(), for: "thread/rollback")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-2", reviewThreadID: "thread-1"), for: "review/start")
- let rollbackGate = AsyncGate()
- await transport.holdNext(method: "thread/rollback", gate: rollbackGate)
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let run = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-old",
- reviewThreadID: "thread-1",
- model: "gpt-5"
- )
- let initialEvents = await eventSequence(backend, run)
- defer { withExtendedLifetime(initialEvents) {} }
-
- async let recovered = backend.resumeReviewRecovery(
- run,
- request: CodexReviewBackendModel.Review.Start(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main")),
- model: "gpt-5"
- ),
- reason: .init(message: "Network unavailable; waiting to reconnect.")
- )
- let rollbackRequested = await waitUntil {
- await transport.recordedRequests().contains { $0.method == "thread/rollback" }
- }
- #expect(rollbackRequested)
-
- try await transport.emitServerNotification(
- method: "turn/started",
- params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "turn-active"))
- )
- let ignoredStaleNotification = await waitUntil {
- await backend.notificationRouterMetricsForTesting().ignored == 1
- }
- #expect(ignoredStaleNotification)
-
- await rollbackGate.open()
- let recoveredRun = try await recovered
- #expect(recoveredRun.turnID == "turn-2")
- let recoveredEvents = await eventSequence(backend, recoveredRun)
- var iterator = recoveredEvents.makeAsyncIterator()
- try await transport.emitServerNotification(
- method: "turn/started",
- params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "turn-2"))
- )
- #expect(try await iterator.next() == .started(
- turnID: "turn-2",
- reviewThreadID: "thread-1",
- model: nil
- ))
- }
-
- @Test func backendRecoveryClearsInterruptedCommandStateBeforeReplayingRecoveredTurn() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
- try await transport.enqueue(EmptyResponse(), for: "thread/rollback")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-2", reviewThreadID: "thread-1"), for: "review/start")
- let reviewStartGate = AsyncGate()
- await transport.hold(method: "review/start", gate: reviewStartGate)
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let run = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-1",
- reviewThreadID: "thread-1",
- model: "gpt-5"
- )
- let events = await eventSequence(backend, run)
- var iterator = events.makeAsyncIterator()
-
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "commandExecution", id: "cmd-1", command: "swift test")
- )
- )
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .logEntry(
- kind: .command,
- text: "$ swift test",
- groupID: "cmd-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "commandExecution",
- status: "inProgress",
- itemID: "cmd-1",
- command: "swift test",
- commandStatus: "inProgress"
- )
- ))
- try await transport.emitServerNotification(
- method: "item/commandExecution/outputDelta",
- params: TestDeltaNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- itemID: "cmd-1",
- delta: "old output"
- )
- )
-
- async let recovered = backend.resumeReviewRecovery(
- run,
- request: CodexReviewBackendModel.Review.Start(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main")),
- model: "gpt-5"
- ),
- reason: .init(message: "Network unavailable; waiting to reconnect.")
- )
- await transport.waitForRequestCount(4)
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "enteredReviewMode", id: "stale-review", review: "stale changes")
- )
- )
- try await transport.emitServerNotification(
- method: "turn/started",
- params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "turn-2"))
- )
-
- await reviewStartGate.open()
- let recoveredRun = try await recovered
-
- #expect(recoveredRun.turnID == "turn-2")
- let recoveredEvents = await eventSequence(backend, recoveredRun)
- var recoveredIterator = recoveredEvents.makeAsyncIterator()
- #expect(try await recoveredIterator.next() == .started(
- turnID: "turn-2",
- reviewThreadID: "thread-1",
- model: nil
- ))
- try await transport.emitServerNotification(
- method: "turn/completed",
- params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "turn-2", status: "completed"))
- )
- #expect(try await recoveredIterator.next() == .completed(summary: "Succeeded.", result: nil))
- }
-
- @Test func backendCleanupDeletesAllRecoveryReviewThreads() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
- try await transport.enqueue(EmptyResponse(), for: "thread/rollback")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-2", reviewThreadID: "review-thread-2"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let run = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-1",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
-
- let recovered = try await backend.resumeReviewRecovery(
- run,
- request: .init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main")),
- model: "gpt-5"
- ),
- reason: .init(message: "Network unavailable; waiting to reconnect.")
- )
- await backend.cleanupReview(recovered)
-
- let deleteThreadIDs = try await transport.recordedRequests()
- .filter { $0.method == "thread/delete" }
- .map { request in
- try JSONDecoder().decode(AppServerAPI.Thread.Delete.Params.self, from: request.params).threadID
- }
- #expect(deleteThreadIDs == [
- "review-thread-1",
- "review-thread-2",
- "thread-1",
- ])
- }
-
- @Test func backendTracksActualStartedTurnAndStreamsReviewItems() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-response", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- let events = await eventSequence(backend, run)
-
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-new",
- item: .init(type: "enteredReviewMode", id: "review-item-1", review: "current changes")
- )
- )
- try await transport.emitServerNotification(
- method: "item/agentMessage/delta",
- params: TestDeltaNotification(threadID: "thread-1", turnID: "turn-new", itemID: "message-1", delta: " hello")
- )
- try await transport.emitServerNotification(
- method: "turn/completed",
- params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "turn-new", status: "completed"))
- )
- try await transport.emitServerNotification(
- method: "item/completed",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-new",
- item: .init(type: "exitedReviewMode", id: "review-item-1", review: "final review text")
- )
- )
-
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .started(turnID: "turn-new", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .logEntry(
- kind: .progress,
- text: "Reviewing current changes",
- groupID: "review-item-1",
- replacesGroup: true
- ))
- #expect(try await iterator.next() == .messageDelta(" hello", itemID: "message-1"))
- #expect(try await iterator.next() == .logEntry(
- kind: .agentMessage,
- text: "final review text",
- groupID: "review-item-1",
- replacesGroup: true
- ))
- #expect(try await iterator.next() == .completed(summary: "Succeeded.", result: "final review text"))
- }
-
- @Test func backendIgnoresTerminalNotificationFromStaleObservedTurn() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-old", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- let events = await eventSequence(backend, run)
-
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-new",
- item: .init(type: "enteredReviewMode", id: "review-item-1", review: "current changes")
- )
- )
- try await transport.emitServerNotification(
- method: "turn/completed",
- params: TestTurnNotification(
- threadID: "thread-1",
- turn: .init(id: "turn-stale", status: "failed", error: .init(message: "Old turn failed"))
- )
- )
- try await transport.emitServerNotification(
- method: "item/agentMessage/delta",
- params: TestDeltaNotification(threadID: "thread-1", turnID: "turn-new", itemID: "message-1", delta: " current")
- )
- try await transport.emitServerNotification(
- method: "turn/completed",
- params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "turn-new", status: "completed"))
- )
- try await transport.emitServerNotification(
- method: "item/completed",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-new",
- item: .init(type: "exitedReviewMode", id: "review-item-1", review: "final review text")
- )
- )
-
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .started(turnID: "turn-new", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .logEntry(
- kind: .progress,
- text: "Reviewing current changes",
- groupID: "review-item-1",
- replacesGroup: true
- ))
- #expect(try await iterator.next() == .messageDelta(" current", itemID: "message-1"))
- #expect(try await iterator.next() == .logEntry(
- kind: .agentMessage,
- text: "final review text",
- groupID: "review-item-1",
- replacesGroup: true
- ))
- #expect(try await iterator.next() == .completed(summary: "Succeeded.", result: "final review text"))
- }
-
- @Test func backendIgnoresNonTerminalNotificationFromStaleObservedTurn() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-response", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- let events = await eventSequence(backend, run)
-
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-new",
- item: .init(type: "enteredReviewMode", id: "review-item-1", review: "current changes")
- )
- )
- try await transport.emitServerNotification(
- method: "error",
- params: TestErrorNotification(
- threadID: "thread-1",
- turnID: "turn-stale",
- message: "Retrying stale turn",
- willRetry: true
- )
- )
- try await transport.emitServerNotification(
- method: "turn/completed",
- params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "turn-new", status: "completed"))
- )
- try await transport.emitServerNotification(
- method: "item/completed",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-new",
- item: .init(type: "exitedReviewMode", id: "review-item-1", review: "final review text")
- )
- )
-
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .started(turnID: "turn-new", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .logEntry(
- kind: .progress,
- text: "Reviewing current changes",
- groupID: "review-item-1",
- replacesGroup: true
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .agentMessage,
- text: "final review text",
- groupID: "review-item-1",
- replacesGroup: true
- ))
- #expect(try await iterator.next() == .completed(summary: "Succeeded.", result: "final review text"))
- }
-
- @Test func backendDoesNotCloseIgnoredTurnCommandLifecycleOnTrackedCompletion() async throws {
- let run = CodexReviewBackendModel.Review.Run(threadID: "thread-1", turnID: "turn-current")
- let transport = FakeJSONRPCTransport()
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let events = await eventSequence(backend, run)
-
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-ignored",
- item: .init(type: "commandExecution", id: "cmd-ignored", command: "git diff")
- )
- )
- try await transport.emitServerNotification(
- method: "turn/completed",
- params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "turn-current", status: "completed"))
- )
-
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .completed(summary: "Succeeded.", result: nil))
- #expect(try await iterator.next() == nil)
- }
-
- @Test func backendFailsWhenReviewThreadBecomesNotLoaded() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- let events = await eventSequence(backend, run)
-
- try await transport.emitServerNotification(
- method: "thread/status/changed",
- params: TestThreadStatusNotification(threadID: "thread-1", status: .init(type: "notLoaded"))
- )
-
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .failed("Review thread is no longer loaded."))
- #expect(try await iterator.next() == nil)
- }
-
- @Test func backendWaitsForDetailedFailureAfterSystemErrorStatus() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- let events = await eventSequence(backend, run)
-
- try await transport.emitServerNotification(
- method: "thread/status/changed",
- params: TestThreadStatusNotification(threadID: "thread-1", status: .init(type: "systemError"))
- )
- try await transport.emitServerNotification(
- method: "error",
- params: TestErrorNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- message: "Detailed failure",
- willRetry: false
- )
- )
-
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .logEntry(
- kind: .diagnostic,
- text: "Review thread entered a system error state.",
- groupID: nil,
- replacesGroup: false
- ))
- #expect(try await iterator.next() == .failed("Detailed failure"))
- #expect(try await iterator.next() == nil)
- }
-
- @Test func backendFailsWhenReviewThreadCloses() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- let events = await eventSequence(backend, run)
-
- try await transport.emitServerNotification(
- method: "thread/closed",
- params: TestThreadClosedNotification(threadID: "thread-1")
- )
-
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .failed("Review thread closed."))
- #expect(try await iterator.next() == nil)
- }
-
- @Test func backendInterruptFinishesReviewEventStream() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- let events = await eventSequence(backend, run)
- var iterator = events.makeAsyncIterator()
- try await transport.emitServerNotification(
- method: "turn/started",
- params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "turn-1"))
- )
-
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
-
- try await backend.interruptReview(run, reason: .init(message: "Stop"))
-
- #expect(try await iterator.next() == .cancelled("Stop"))
- #expect(try await iterator.next() == nil)
- }
-
- @Test func backendInterruptClosesActiveCommandLifecycle() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- let events = await eventSequence(backend, run)
- var iterator = events.makeAsyncIterator()
- let startedAtMs: Int64 = 1_700_000_000_000
- let startedAt = Date(timeIntervalSince1970: TimeInterval(startedAtMs) / 1_000)
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "commandExecution", id: "cmd-1", command: "git diff"),
- startedAtMs: startedAtMs
- )
- )
-
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .logEntry(
- kind: .command,
- text: "$ git diff",
- groupID: "cmd-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "commandExecution",
- status: "inProgress",
- itemID: "cmd-1",
- command: "git diff",
- startedAt: startedAt,
- commandStatus: "inProgress"
- )
- ))
-
- try await backend.interruptReview(run, reason: .init(message: "Stop"))
-
- guard case .logEntry(let kind, let text, let groupID, let replacesGroup, let metadata) = try await iterator.next()
- else {
- Issue.record("Expected active command to be closed before cancellation.")
- return
- }
- #expect(kind == .command)
- #expect(text == "$ git diff")
- #expect(groupID == "cmd-1")
- #expect(replacesGroup == true)
- #expect(metadata?.sourceType == "commandExecution")
- #expect(metadata?.status == "canceled")
- #expect(metadata?.itemID == "cmd-1")
- #expect(metadata?.command == "git diff")
- #expect(metadata?.startedAt == startedAt)
- #expect(metadata?.completedAt != nil)
- #expect(metadata?.durationMs != nil)
- #expect(metadata?.commandStatus == "canceled")
- #expect(try await iterator.next() == .cancelled("Stop"))
- #expect(try await iterator.next() == nil)
- }
-
- @Test func backendInterruptClosesActiveCommandOutputLifecycle() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- let events = await eventSequence(backend, run)
- var iterator = events.makeAsyncIterator()
- let startedAtMs: Int64 = 1_700_000_000_000
- let startedAt = Date(timeIntervalSince1970: TimeInterval(startedAtMs) / 1_000)
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "commandExecution", id: "cmd-1", command: "git status"),
- startedAtMs: startedAtMs
- )
- )
- try await transport.emitServerNotification(
- method: "item/commandExecution/outputDelta",
- params: TestDeltaNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- itemID: "cmd-1",
- delta: " M README.md\n"
- )
- )
- try await transport.emitServerNotification(
- method: "item/commandExecution/outputDelta",
- params: TestDeltaNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- itemID: "cmd-1",
- delta: "?? Sources/New.swift\n"
- )
- )
-
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- _ = try await iterator.next()
-
- let routedOutput = await waitUntil {
- await backend.reviewEventSessionMetricsForTesting(threadID: "thread-1")?.routed ?? 0 >= 3
- }
- #expect(routedOutput)
-
- try await backend.interruptReview(run, reason: .init(message: "Stop"))
-
- var sawClosedOutput = false
- while let event = try await iterator.next() {
- if case .cancelled("Stop") = event {
- break
- }
- guard case .logEntry(let kind, let text, let groupID, let replacesGroup, let metadata) = event,
- kind == .commandOutput,
- replacesGroup
- else {
- continue
- }
- sawClosedOutput = true
- #expect(text == " M README.md\n?? Sources/New.swift\n")
- #expect(groupID == "cmd-1")
- #expect(metadata?.sourceType == "commandExecution")
- #expect(metadata?.status == "canceled")
- #expect(metadata?.itemID == "cmd-1")
- #expect(metadata?.command == "git status")
- #expect(metadata?.startedAt == startedAt)
- #expect(metadata?.completedAt != nil)
- #expect(metadata?.durationMs != nil)
- #expect(metadata?.commandStatus == "canceled")
- }
- #expect(sawClosedOutput)
- #expect(try await iterator.next() == nil)
- }
-
- @Test func backendCoalescesReasoningSummaryDeltasBeforeNextEvent() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- let events = await eventSequence(backend, run)
- try await transport.emitServerNotification(
- method: "item/reasoning/summaryTextDelta",
- params: TestDeltaNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- itemID: "reasoning-1",
- delta: "Need to "
- )
- )
- try await transport.emitServerNotification(
- method: "item/reasoning/summaryTextDelta",
- params: TestDeltaNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- itemID: "reasoning-1",
- delta: "inspect logs."
- )
- )
- try await transport.emitServerNotification(
- method: "log",
- params: TestMessageNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- message: "Continuing."
- )
- )
-
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .logEntry(
- kind: .reasoningSummary,
- text: "Need to inspect logs.",
- groupID: "reasoning-1:summary:0",
- replacesGroup: false
- ))
- #expect(try await iterator.next() == .log("Continuing."))
- }
-
- @Test func backendRebindsObservedTurnAndInterruptsLatestTurn() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-old", reviewThreadID: "thread-1"), for: "review/start")
- try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- let events = await eventSequence(backend, run)
-
- try await transport.emitServerNotification(
- method: "turn/started",
- params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "turn-old"))
- )
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-new",
- item: .init(type: "enteredReviewMode", id: "review-item-1", review: "current changes")
- )
- )
-
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .started(turnID: "turn-old", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .started(turnID: "turn-new", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .logEntry(
- kind: .progress,
- text: "Reviewing current changes",
- groupID: "review-item-1",
- replacesGroup: true
- ))
-
- try await backend.interruptReview(run, reason: .init())
-
- let request = try #require(await transport.recordedRequests().last)
- let params = try JSONDecoder().decode(AppServerAPI.Turn.Interrupt.Params.self, from: request.params)
- #expect(params.turnID == "turn-new")
- }
-
- @Test func backendKeepsReviewModeCompletionAfterTurnRebind() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-old", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- let events = await eventSequence(backend, run)
-
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-old",
- item: .init(type: "enteredReviewMode", id: "review-item-1", review: "current changes")
- )
- )
- try await transport.emitServerNotification(
- method: "turn/started",
- params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "turn-new"))
- )
- try await transport.emitServerNotification(
- method: "turn/completed",
- params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "turn-old", status: "completed"))
- )
- try await transport.emitServerNotification(
- method: "item/completed",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-old",
- item: .init(type: "exitedReviewMode", id: "review-item-1", review: "final review text")
- )
- )
- try await transport.emitServerNotification(
- method: "turn/completed",
- params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "turn-new", status: "completed"))
- )
-
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .started(turnID: "turn-old", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .logEntry(
- kind: .progress,
- text: "Reviewing current changes",
- groupID: "review-item-1",
- replacesGroup: true
- ))
- #expect(try await iterator.next() == .started(turnID: "turn-new", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .logEntry(
- kind: .agentMessage,
- text: "final review text",
- groupID: "review-item-1",
- replacesGroup: true
- ))
- #expect(try await iterator.next() == .completed(summary: "Succeeded.", result: "final review text"))
- }
-
- @Test func backendBindsActualTurnFromFirstReviewItemWhenStartedNotificationIsMissing() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-old", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- let events = await eventSequence(backend, run)
-
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-new",
- item: .init(type: "enteredReviewMode", id: "review-item-1", review: "current changes")
- )
- )
- try await transport.emitServerNotification(
- method: "item/reasoning/summaryTextDelta",
- params: TestDeltaNotification(
- threadID: "thread-1",
- turnID: "turn-new",
- itemID: "reasoning-1",
- delta: " Checking diff"
- )
- )
- try await transport.emitServerNotification(
- method: "turn/completed",
- params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "turn-new", status: "completed"))
- )
- try await transport.emitServerNotification(
- method: "item/completed",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-new",
- item: .init(type: "exitedReviewMode", id: "review-item-1", review: "final review text")
- )
- )
-
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .started(turnID: "turn-new", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .logEntry(
- kind: .progress,
- text: "Reviewing current changes",
- groupID: "review-item-1",
- replacesGroup: true
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .reasoningSummary,
- text: " Checking diff",
- groupID: "reasoning-1:summary:0",
- replacesGroup: false
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .agentMessage,
- text: "final review text",
- groupID: "review-item-1",
- replacesGroup: true
- ))
- #expect(try await iterator.next() == .completed(summary: "Succeeded.", result: "final review text"))
- }
-
- @Test func backendKeepsReviewResponseTurnWhenAuxiliaryTurnStarts() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "review-turn", reviewThreadID: "thread-1"), for: "review/start")
- try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- let events = await eventSequence(backend, run)
-
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "review-turn",
- item: .init(type: "enteredReviewMode", id: "review-item-1", review: "current changes")
- )
- )
- try await transport.emitServerNotification(
- method: "turn/started",
- params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "active-turn"))
- )
- try await transport.emitServerNotification(
- method: "item/agentMessage/delta",
- params: TestDeltaNotification(
- threadID: "thread-1",
- turnID: "review-turn",
- itemID: "message-1",
- delta: "review output"
- )
- )
-
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .started(turnID: "review-turn", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .logEntry(
- kind: .progress,
- text: "Reviewing current changes",
- groupID: "review-item-1",
- replacesGroup: true
- ))
- #expect(try await iterator.next() == .started(turnID: "active-turn", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .messageDelta("review output", itemID: "message-1"))
-
- try await backend.interruptReview(run, reason: .init())
-
- let params = try JSONDecoder().decode(
- AppServerAPI.Turn.Interrupt.Params.self,
- from: try #require(await transport.recordedRequests().last?.params)
- )
- #expect(params.turnID == "active-turn")
- }
-
- @Test func backendMapsReviewItemAndDiagnosticNotificationsToLogEntries() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- let events = await eventSequence(backend, run)
-
- try await transport.emitServerNotification(
- method: "turn/plan/updated",
- params: TestPlanNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- plan: [
- .init(step: "Inspect diff", status: "inProgress"),
- .init(step: "Write findings", status: "pending"),
- ]
- )
- )
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "commandExecution", id: "cmd-1", command: "swift test")
- )
- )
- try await transport.emitServerNotification(
- method: "item/commandExecution/outputDelta",
- params: TestDeltaNotification(threadID: "thread-1", turnID: "turn-1", itemID: "cmd-1", delta: "Tests")
- )
- try await transport.emitServerNotification(
- method: "item/completed",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "commandExecution", id: "cmd-1", aggregatedOutput: "Tests passed")
- )
- )
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "commandExecution", id: "cmd-2", command: "pwd")
- )
- )
- try await transport.emitServerNotification(
- method: "item/completed",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "commandExecution", id: "cmd-2", command: "pwd")
- )
- )
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "mcpToolCall", id: "tool-1", status: "inProgress", server: "codex_review", tool: "review_read")
- )
- )
- try await transport.emitServerNotification(
- method: "item/mcpToolCall/progress",
- params: TestMessageNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- itemID: "tool-1",
- message: "Reading review job"
- )
- )
- try await transport.emitServerNotification(
- method: "item/completed",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(
- type: "mcpToolCall",
- id: "tool-1",
- status: "completed",
- server: "codex_review",
- tool: "review_read",
- result: "ok"
- )
- )
- )
- try await transport.emitServerNotification(
- method: "item/reasoning/summaryTextDelta",
- params: TestDeltaNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- itemID: "reasoning-1",
- delta: "summary",
- summaryIndex: 1
- )
- )
- try await transport.emitServerNotification(
- method: "item/reasoning/textDelta",
- params: TestDeltaNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- itemID: "reasoning-1",
- delta: "raw chain",
- contentIndex: 2
- )
- )
- try await transport.emitServerNotification(
- method: "item/completed",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(
- type: "reasoning",
- id: "reasoning-1",
- summary: ["first final", "summary replacement"],
- content: ["raw final", "other raw", "raw chain plus final"]
- )
- )
- )
- try await transport.emitServerNotification(
- method: "warning",
- params: TestMessageNotification(threadID: "thread-1", turnID: "turn-1", message: "Model warning")
- )
- try await transport.emitServerNotification(
- method: "deprecationNotice",
- params: TestDiagnosticNotification(summary: "Deprecated thing", details: "Use newer thing.")
- )
- try await transport.emitServerNotification(
- method: "model/rerouted",
- params: TestModelReroutedNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- fromModel: "gpt-5.4",
- toModel: "gpt-5.5",
- reason: "highRiskCyberActivity"
- )
- )
- try await transport.emitServerNotification(
- method: "turn/diff/updated",
- params: TestDiffNotification(threadID: "thread-1", turnID: "turn-1", diff: "diff --git")
- )
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "contextCompaction", id: "compact-1")
- )
- )
- try await transport.emitServerNotification(
- method: "item/completed",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "contextCompaction", id: "compact-1")
- )
- )
- try await transport.emitServerNotification(
- method: "turn/completed",
- params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "turn-1", status: "completed"))
- )
-
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .logEntry(
- kind: .todoList,
- text: "[inProgress] Inspect diff\n[pending] Write findings",
- groupID: "turn-1",
- replacesGroup: true
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .command,
- text: "$ swift test",
- groupID: "cmd-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "commandExecution",
- status: "inProgress",
- itemID: "cmd-1",
- command: "swift test",
- commandStatus: "inProgress"
- )
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .commandOutput,
- text: "Tests",
- groupID: "cmd-1",
- replacesGroup: false,
- metadata: .init(sourceType: "commandExecution", title: "Command output", itemID: "cmd-1")
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .command,
- text: "$ swift test",
- groupID: "cmd-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "commandExecution",
- status: "completed",
- itemID: "cmd-1",
- command: "swift test",
- commandStatus: "completed"
- )
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .commandOutput,
- text: "Tests passed",
- groupID: "cmd-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "commandExecution",
- status: "completed",
- itemID: "cmd-1",
- command: "swift test",
- commandStatus: "completed"
- )
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .command,
- text: "$ pwd",
- groupID: "cmd-2",
- replacesGroup: true,
- metadata: .init(
- sourceType: "commandExecution",
- status: "inProgress",
- itemID: "cmd-2",
- command: "pwd",
- commandStatus: "inProgress"
- )
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .command,
- text: "$ pwd",
- groupID: "cmd-2",
- replacesGroup: true,
- metadata: .init(
- sourceType: "commandExecution",
- status: "completed",
- itemID: "cmd-2",
- command: "pwd",
- commandStatus: "completed"
- )
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .toolCall,
- text: "MCP codex_review.review_read started.",
- groupID: "tool-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "mcpToolCall",
- title: "codex_review.review_read",
- status: "started",
- server: "codex_review",
- tool: "review_read"
- )
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .toolCall,
- text: "Reading review job",
- groupID: "tool-1",
- replacesGroup: false,
- metadata: .init(sourceType: "mcpToolCall", title: "Tool progress")
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .toolCall,
- text: "ok",
- groupID: "tool-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "mcpToolCall",
- title: "codex_review.review_read",
- status: "completed",
- server: "codex_review",
- tool: "review_read",
- resultText: "ok"
- )
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .reasoningSummary,
- text: "summary",
- groupID: "reasoning-1:summary:1",
- replacesGroup: false
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .rawReasoning,
- text: "raw chain",
- groupID: "reasoning-1:2",
- replacesGroup: false
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .reasoningSummary,
- text: "first final",
- groupID: "reasoning-1:summary:0",
- replacesGroup: true
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .reasoningSummary,
- text: "summary replacement",
- groupID: "reasoning-1:summary:1",
- replacesGroup: true
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .rawReasoning,
- text: "raw final",
- groupID: "reasoning-1:0",
- replacesGroup: true
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .rawReasoning,
- text: "other raw",
- groupID: "reasoning-1:1",
- replacesGroup: true
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .rawReasoning,
- text: "raw chain plus final",
- groupID: "reasoning-1:2",
- replacesGroup: true
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .diagnostic,
- text: "Model warning",
- groupID: "turn-1",
- replacesGroup: false
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .diagnostic,
- text: "Deprecated thing\nUse newer thing.",
- groupID: nil,
- replacesGroup: false
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .event,
- text: "Model rerouted: gpt-5.4 -> gpt-5.5 (highRiskCyberActivity).",
- groupID: "turn-1",
- replacesGroup: false
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .event,
- text: "diff --git",
- groupID: "turn-1",
- replacesGroup: true
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .contextCompaction,
- text: "Automatically compacting context",
- groupID: "compact-1",
- replacesGroup: true,
- metadata: .init(sourceType: "contextCompaction", status: "inProgress", itemID: "compact-1")
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .contextCompaction,
- text: "Context automatically compacted",
- groupID: "compact-1",
- replacesGroup: true,
- metadata: .init(sourceType: "contextCompaction", status: "completed", itemID: "compact-1")
- ))
- #expect(try await iterator.next() == .completed(summary: "Succeeded.", result: nil))
- }
-
- @Test func backendMapsExitedReviewModeItemToFinalAgentMessage() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- let events = await eventSequence(backend, run)
-
- try await transport.emitServerNotification(
- method: "item/completed",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "exitedReviewMode", id: "review-item-1", review: "final review text")
- )
- )
- try await transport.emitServerNotification(
- method: "turn/completed",
- params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "turn-1", status: "completed"))
- )
-
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .logEntry(
- kind: .agentMessage,
- text: "final review text",
- groupID: "review-item-1",
- replacesGroup: true
- ))
- #expect(try await iterator.next() == .completed(summary: "Succeeded.", result: "final review text"))
- }
-
- @Test func backendPreservesCommandLifecycleMetadata() async throws {
- let run = CodexReviewBackendModel.Review.Run(threadID: "thread-1", turnID: "turn-1")
- let transport = FakeJSONRPCTransport()
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let events = await eventSequence(backend, run)
- let startedAtMs: Int64 = 1_700_000_000_000
- let completedAtMs: Int64 = startedAtMs + 3_456
-
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(
- type: "commandExecution",
- id: "cmd-1",
- command: "cat Sources/ThreadItem.ts",
- commandActions: [
- .read(command: "cat Sources/ThreadItem.ts", name: "ThreadItem.ts", path: "Sources/ThreadItem.ts")
- ],
- status: "inProgress"
- ),
- startedAtMs: startedAtMs
- )
- )
- try await transport.emitServerNotification(
- method: "item/completed",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(
- type: "commandExecution",
- id: "cmd-1",
- aggregatedOutput: "file contents",
- exitCode: 0,
- durationMs: 3_000
- ),
- completedAtMs: completedAtMs
- )
- )
-
- let startedAt = Date(timeIntervalSince1970: TimeInterval(startedAtMs) / 1_000)
- let completedAt = Date(timeIntervalSince1970: TimeInterval(completedAtMs) / 1_000)
- let action = ReviewLogEntry.Metadata.CommandAction(
- kind: .read,
- command: "cat Sources/ThreadItem.ts",
- name: "ThreadItem.ts",
- path: "Sources/ThreadItem.ts"
- )
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .logEntry(
- kind: .command,
- text: "$ cat Sources/ThreadItem.ts",
- groupID: "cmd-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "commandExecution",
- status: "inProgress",
- itemID: "cmd-1",
- command: "cat Sources/ThreadItem.ts",
- startedAt: startedAt,
- commandActions: [action],
- commandStatus: "inProgress"
- )
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .command,
- text: "$ cat Sources/ThreadItem.ts",
- groupID: "cmd-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "commandExecution",
- status: "succeeded",
- itemID: "cmd-1",
- command: "cat Sources/ThreadItem.ts",
- exitCode: 0,
- startedAt: startedAt,
- completedAt: completedAt,
- durationMs: 3_000,
- commandActions: [action],
- commandStatus: "succeeded"
- )
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .commandOutput,
- text: "file contents",
- groupID: "cmd-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "commandExecution",
- status: "succeeded",
- itemID: "cmd-1",
- command: "cat Sources/ThreadItem.ts",
- exitCode: 0,
- startedAt: startedAt,
- completedAt: completedAt,
- durationMs: 3_000,
- commandActions: [action],
- commandStatus: "succeeded"
- )
- ))
- }
-
- @Test func backendDerivesFailedCommandDurationWhenCompletedItemReportsZero() async throws {
- let run = CodexReviewBackendModel.Review.Run(threadID: "thread-1", turnID: "turn-1")
- let transport = FakeJSONRPCTransport()
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let events = await eventSequence(backend, run)
- let startedAtMs: Int64 = 1_700_000_000_000
- let completedAtMs: Int64 = startedAtMs + 10_007
-
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(
- type: "commandExecution",
- id: "cmd-failed",
- command: "git diff -- Sources/CodexReview"
- ),
- startedAtMs: startedAtMs
- )
- )
- try await transport.emitServerNotification(
- method: "item/completed",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(
- type: "commandExecution",
- id: "cmd-failed",
- command: "git diff -- Sources/CodexReview",
- aggregatedOutput: "execution error: No such process",
- exitCode: -1,
- durationMs: 0,
- status: "failed"
- ),
- completedAtMs: completedAtMs
- )
- )
-
- let startedAt = Date(timeIntervalSince1970: TimeInterval(startedAtMs) / 1_000)
- let completedAt = Date(timeIntervalSince1970: TimeInterval(completedAtMs) / 1_000)
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .logEntry(
- kind: .command,
- text: "$ git diff -- Sources/CodexReview",
- groupID: "cmd-failed",
- replacesGroup: true,
- metadata: .init(
- sourceType: "commandExecution",
- status: "inProgress",
- itemID: "cmd-failed",
- command: "git diff -- Sources/CodexReview",
- startedAt: startedAt,
- commandStatus: "inProgress"
- )
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .command,
- text: "$ git diff -- Sources/CodexReview",
- groupID: "cmd-failed",
- replacesGroup: true,
- metadata: .init(
- sourceType: "commandExecution",
- status: "failed",
- itemID: "cmd-failed",
- command: "git diff -- Sources/CodexReview",
- exitCode: -1,
- startedAt: startedAt,
- completedAt: completedAt,
- durationMs: 10_007,
- commandStatus: "failed"
- )
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .commandOutput,
- text: "execution error: No such process",
- groupID: "cmd-failed",
- replacesGroup: true,
- metadata: .init(
- sourceType: "commandExecution",
- status: "failed",
- itemID: "cmd-failed",
- command: "git diff -- Sources/CodexReview",
- exitCode: -1,
- startedAt: startedAt,
- completedAt: completedAt,
- durationMs: 10_007,
- commandStatus: "failed"
- )
- ))
- }
-
- @Test func backendPreservesContextCompactionLifecycleMetadata() async throws {
- let run = CodexReviewBackendModel.Review.Run(threadID: "thread-1", turnID: "turn-1")
- let transport = FakeJSONRPCTransport()
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let events = await eventSequence(backend, run, includingDomainEvents: true)
- let startedAtMs: Int64 = 1_700_000_000_000
- let completedAtMs: Int64 = startedAtMs + 2_000
-
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "contextCompaction", id: "compact-1"),
- startedAtMs: startedAtMs
- )
- )
- try await transport.emitServerNotification(
- method: "item/completed",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "contextCompaction", id: "compact-1"),
- completedAtMs: completedAtMs
- )
- )
-
- let startedAt = Date(timeIntervalSince1970: TimeInterval(startedAtMs) / 1_000)
- let completedAt = Date(timeIntervalSince1970: TimeInterval(completedAtMs) / 1_000)
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .logEntry(
- kind: .contextCompaction,
- text: "Automatically compacting context",
- groupID: "compact-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "contextCompaction",
- status: "inProgress",
- itemID: "compact-1",
- startedAt: startedAt
- )
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .contextCompaction,
- text: "Context automatically compacted",
- groupID: "compact-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "contextCompaction",
- status: "completed",
- itemID: "compact-1",
- completedAt: completedAt
- )
- ))
- }
-
- @Test func backendPreservesFailedContextCompactionCompletionStatus() async throws {
- let run = CodexReviewBackendModel.Review.Run(threadID: "thread-1", turnID: "turn-1")
- let transport = FakeJSONRPCTransport()
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let events = await eventSequence(backend, run)
- let completedAtMs: Int64 = 1_700_000_002_000
-
- try await transport.emitServerNotification(
- method: "item/completed",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(
- type: "contextCompaction",
- id: "compact-1",
- status: "failed",
- error: "compaction failed"
- ),
- completedAtMs: completedAtMs
- )
- )
-
- let completedAt = Date(timeIntervalSince1970: TimeInterval(completedAtMs) / 1_000)
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .logEntry(
- kind: .contextCompaction,
- text: "Context compaction failed",
- groupID: "compact-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "contextCompaction",
- status: "failed",
- itemID: "compact-1",
- completedAt: completedAt,
- errorText: "compaction failed"
- )
- ))
- }
-
- @Test func backendMapsDeprecatedThreadCompactedToCompletedContextCompactionMarker() async throws {
- let run = CodexReviewBackendModel.Review.Run(threadID: "thread-1", turnID: "turn-1")
- let transport = FakeJSONRPCTransport()
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let events = await eventSequence(backend, run)
-
- try await transport.emitServerNotification(
- method: "thread/compacted",
- params: TestContextCompactedNotification(threadID: "thread-1", turnID: "turn-1")
- )
-
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .logEntry(
- kind: .contextCompaction,
- text: "Context automatically compacted",
- groupID: "contextCompaction:turn-1",
- replacesGroup: true,
- metadata: .init(sourceType: "contextCompaction", status: "completed")
- ))
- }
-
- @Test func backendFallsBackCommandDurationToLifecycleDates() async throws {
- let run = CodexReviewBackendModel.Review.Run(threadID: "thread-1", turnID: "turn-1")
- let transport = FakeJSONRPCTransport()
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let events = await eventSequence(backend, run)
-
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "commandExecution", id: "cmd-1", command: "swift test"),
- startedAtMs: 2_000
- )
- )
- try await transport.emitServerNotification(
- method: "item/completed",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "commandExecution", id: "cmd-1", command: "swift test"),
- completedAtMs: 5_250
- )
- )
-
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .logEntry(
- kind: .command,
- text: "$ swift test",
- groupID: "cmd-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "commandExecution",
- status: "inProgress",
- itemID: "cmd-1",
- command: "swift test",
- startedAt: Date(timeIntervalSince1970: 2),
- commandStatus: "inProgress"
- )
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .command,
- text: "$ swift test",
- groupID: "cmd-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "commandExecution",
- status: "completed",
- itemID: "cmd-1",
- command: "swift test",
- startedAt: Date(timeIntervalSince1970: 2),
- completedAt: Date(timeIntervalSince1970: 5.25),
- durationMs: 3_250,
- commandStatus: "completed"
- )
- ))
- }
-
- @Test func backendCompletesStreamedCommandOutputWhenCompletionHasNoAggregatedOutput() async throws {
- let run = CodexReviewBackendModel.Review.Run(threadID: "thread-1", turnID: "turn-1")
- let transport = FakeJSONRPCTransport()
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let events = await eventSequence(backend, run)
-
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "commandExecution", id: "cmd-1", command: "swift test"),
- startedAtMs: 2_000
- )
- )
- try await transport.emitServerNotification(
- method: "item/commandExecution/outputDelta",
- params: TestDeltaNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- itemID: "cmd-1",
- delta: " Tests passed\n"
- )
- )
- try await transport.emitServerNotification(
- method: "item/completed",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "commandExecution", id: "cmd-1", command: "swift test", exitCode: 0),
- completedAtMs: 5_250
- )
- )
-
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .logEntry(
- kind: .command,
- text: "$ swift test",
- groupID: "cmd-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "commandExecution",
- status: "inProgress",
- itemID: "cmd-1",
- command: "swift test",
- startedAt: Date(timeIntervalSince1970: 2),
- commandStatus: "inProgress"
- )
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .commandOutput,
- text: " Tests passed\n",
- groupID: "cmd-1",
- replacesGroup: false,
- metadata: .init(sourceType: "commandExecution", title: "Command output", itemID: "cmd-1")
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .command,
- text: "$ swift test",
- groupID: "cmd-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "commandExecution",
- status: "succeeded",
- itemID: "cmd-1",
- command: "swift test",
- exitCode: 0,
- startedAt: Date(timeIntervalSince1970: 2),
- completedAt: Date(timeIntervalSince1970: 5.25),
- durationMs: 3_250,
- commandStatus: "succeeded"
- )
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .commandOutput,
- text: " Tests passed\n",
- groupID: "cmd-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "commandExecution",
- status: "succeeded",
- itemID: "cmd-1",
- command: "swift test",
- exitCode: 0,
- startedAt: Date(timeIntervalSince1970: 2),
- completedAt: Date(timeIntervalSince1970: 5.25),
- durationMs: 3_250,
- commandStatus: "succeeded"
- )
- ))
- }
-
- @Test func backendAttachesProcessOutputDeltasToCommandLifecycleByProcessID() async throws {
- let run = CodexReviewBackendModel.Review.Run(threadID: "thread-1", turnID: "turn-1")
- let transport = FakeJSONRPCTransport()
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let events = await eventSequence(backend, run)
- let execOutput = "exec output\n"
- let processOutput = "process output\n"
- let combinedOutput = execOutput + processOutput
-
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestCommandExecutionItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(id: "cmd-1", command: "swift test", processID: "proc-1"),
- startedAtMs: 2_000
- )
- )
- try await transport.emitServerNotification(
- method: "command/exec/outputDelta",
- params: TestBase64OutputNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- processID: "proc-1",
- deltaBase64: Data(execOutput.utf8).base64EncodedString()
- )
- )
- try await transport.emitServerNotification(
- method: "process/outputDelta",
- params: TestBase64OutputNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- processHandle: "proc-1",
- deltaBase64: Data(processOutput.utf8).base64EncodedString()
- )
- )
- try await transport.emitServerNotification(
- method: "item/completed",
- params: TestCommandExecutionItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(id: "cmd-1", command: "swift test", processID: "proc-1", exitCode: 0),
- completedAtMs: 5_250
- )
- )
- try await transport.emitServerNotification(
- method: "turn/completed",
- params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "turn-1", status: "completed"))
- )
-
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .logEntry(
- kind: .command,
- text: "$ swift test",
- groupID: "cmd-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "commandExecution",
- status: "inProgress",
- itemID: "cmd-1",
- command: "swift test",
- startedAt: Date(timeIntervalSince1970: 2),
- commandStatus: "inProgress"
- )
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .commandOutput,
- text: combinedOutput,
- groupID: "cmd-1",
- replacesGroup: false,
- metadata: .init(sourceType: "commandExecution", title: "Command output", itemID: "cmd-1")
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .command,
- text: "$ swift test",
- groupID: "cmd-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "commandExecution",
- status: "succeeded",
- itemID: "cmd-1",
- command: "swift test",
- exitCode: 0,
- startedAt: Date(timeIntervalSince1970: 2),
- completedAt: Date(timeIntervalSince1970: 5.25),
- durationMs: 3_250,
- commandStatus: "succeeded"
- )
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .commandOutput,
- text: combinedOutput,
- groupID: "cmd-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "commandExecution",
- status: "succeeded",
- itemID: "cmd-1",
- command: "swift test",
- exitCode: 0,
- startedAt: Date(timeIntervalSince1970: 2),
- completedAt: Date(timeIntervalSince1970: 5.25),
- durationMs: 3_250,
- commandStatus: "succeeded"
- )
- ))
- #expect(try await iterator.next() == .completed(summary: "Succeeded.", result: nil))
- #expect(try await iterator.next() == nil)
- }
-
- @Test func backendFlushesPendingStreamedCommandOutputBeforeNotificationStreamFinishes() async throws {
- let run = CodexReviewBackendModel.Review.Run(threadID: "thread-1", turnID: "turn-1")
- let transport = FakeJSONRPCTransport()
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let events = await eventSequence(backend, run)
-
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "commandExecution", id: "cmd-1", command: "swift test"),
- startedAtMs: 2_000
- )
- )
-
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .logEntry(
- kind: .command,
- text: "$ swift test",
- groupID: "cmd-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "commandExecution",
- status: "inProgress",
- itemID: "cmd-1",
- command: "swift test",
- startedAt: Date(timeIntervalSince1970: 2),
- commandStatus: "inProgress"
- )
- ))
-
- try await transport.emitServerNotification(
- method: "item/commandExecution/outputDelta",
- params: TestDeltaNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- itemID: "cmd-1",
- delta: "tail output\n"
- )
- )
- await transport.close()
-
- #expect(try await iterator.next() == .logEntry(
- kind: .commandOutput,
- text: "tail output\n",
- groupID: "cmd-1",
- replacesGroup: false,
- metadata: .init(sourceType: "commandExecution", title: "Command output", itemID: "cmd-1")
- ))
- #expect(try await iterator.next() == nil)
- }
-
- @Test func backendReviewExitCompletesMissingCommandCompletion() async throws {
- let run = CodexReviewBackendModel.Review.Run(threadID: "thread-1", turnID: "turn-1")
- let transport = FakeJSONRPCTransport()
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let events = await eventSequence(backend, run)
-
- let startedAtMs: Int64 = 2_000
- let startedAt = Date(timeIntervalSince1970: 2)
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "enteredReviewMode", id: "review-item-1", review: "current changes")
- )
- )
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "commandExecution", id: "cmd-1", command: "git diff"),
- startedAtMs: startedAtMs
- )
- )
- try await transport.emitServerNotification(
- method: "item/commandExecution/outputDelta",
- params: TestDeltaNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- itemID: "cmd-1",
- delta: " M README.md\n"
- )
- )
- try await transport.emitServerNotification(
- method: "item/completed",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "exitedReviewMode", id: "review-item-1", review: "final review text")
- )
- )
-
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .logEntry(
- kind: .progress,
- text: "Reviewing current changes",
- groupID: "review-item-1",
- replacesGroup: true
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .command,
- text: "$ git diff",
- groupID: "cmd-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "commandExecution",
- status: "inProgress",
- itemID: "cmd-1",
- command: "git diff",
- startedAt: startedAt,
- commandStatus: "inProgress"
- )
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .commandOutput,
- text: " M README.md\n",
- groupID: "cmd-1",
- replacesGroup: false,
- metadata: .init(sourceType: "commandExecution", title: "Command output", itemID: "cmd-1")
- ))
- guard case .logEntry(let kind, let text, let groupID, let replacesGroup, let metadata) = try await iterator.next()
- else {
- Issue.record("Expected review exit to close the active command execution.")
- return
- }
- #expect(kind == .command)
- #expect(text == "$ git diff")
- #expect(groupID == "cmd-1")
- #expect(replacesGroup == true)
- #expect(metadata?.sourceType == "commandExecution")
- #expect(metadata?.status == "completed")
- #expect(metadata?.itemID == "cmd-1")
- #expect(metadata?.command == "git diff")
- #expect(metadata?.startedAt == startedAt)
- #expect(metadata?.completedAt != nil)
- #expect(metadata?.durationMs != nil)
- #expect(metadata?.commandStatus == "completed")
- guard case .logEntry(let outputKind, let outputText, let outputGroupID, let outputReplacesGroup, let outputMetadata) = try await iterator.next()
- else {
- Issue.record("Expected review exit to close the active command output.")
- return
- }
- #expect(outputKind == .commandOutput)
- #expect(outputText == " M README.md\n")
- #expect(outputGroupID == "cmd-1")
- #expect(outputReplacesGroup == true)
- #expect(outputMetadata?.sourceType == "commandExecution")
- #expect(outputMetadata?.status == "completed")
- #expect(outputMetadata?.itemID == "cmd-1")
- #expect(outputMetadata?.command == "git diff")
- #expect(outputMetadata?.startedAt == startedAt)
- #expect(outputMetadata?.completedAt != nil)
- #expect(outputMetadata?.durationMs != nil)
- #expect(outputMetadata?.commandStatus == "completed")
- #expect(try await iterator.next() == .logEntry(
- kind: .agentMessage,
- text: "final review text",
- groupID: "review-item-1",
- replacesGroup: true
- ))
- #expect(try await iterator.next() == .completed(summary: "Succeeded.", result: "final review text"))
- #expect(try await iterator.next() == nil)
- }
-
- @Test func backendClosesMissingCommandCompletionBeforeFollowingReasoning() async throws {
- let run = CodexReviewBackendModel.Review.Run(threadID: "thread-1", turnID: "turn-1")
- let transport = FakeJSONRPCTransport()
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let events = await eventSequence(backend, run)
-
- let startedAt = Date(timeIntervalSince1970: 2)
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "commandExecution", id: "cmd-1", command: "git diff"),
- startedAtMs: 2_000
- )
- )
- try await transport.emitServerNotification(
- method: "item/commandExecution/outputDelta",
- params: TestDeltaNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- itemID: "cmd-1",
- delta: "diff output\n"
- )
- )
- try await transport.emitServerNotification(
- method: "item/reasoning/summaryTextDelta",
- params: TestDeltaNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- itemID: "reasoning-1",
- delta: "Inspecting diffs"
- )
- )
- try await transport.emitServerNotification(
- method: "item/completed",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "exitedReviewMode", id: "review-item-1", review: "final review text")
- )
- )
+private struct BackendReviewEventSequence: AsyncSequence {
+ struct AsyncIterator: AsyncIteratorProtocol {
+ var mailbox: BackendReviewEventMailbox
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .logEntry(
- kind: .command,
- text: "$ git diff",
- groupID: "cmd-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "commandExecution",
- status: "inProgress",
- itemID: "cmd-1",
- command: "git diff",
- startedAt: startedAt,
- commandStatus: "inProgress"
- )
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .commandOutput,
- text: "diff output\n",
- groupID: "cmd-1",
- replacesGroup: false,
- metadata: .init(sourceType: "commandExecution", title: "Command output", itemID: "cmd-1")
- ))
- guard case .logEntry(let commandKind, _, let commandGroupID, let commandReplacesGroup, let commandMetadata) = try await iterator.next()
- else {
- Issue.record("Expected following reasoning to close the active command execution.")
- return
- }
- #expect(commandKind == .command)
- #expect(commandGroupID == "cmd-1")
- #expect(commandReplacesGroup == true)
- #expect(commandMetadata?.status == "completed")
- #expect(commandMetadata?.itemID == "cmd-1")
- #expect(commandMetadata?.startedAt == startedAt)
- #expect(commandMetadata?.completedAt != nil)
- guard case .logEntry(let outputKind, let outputText, let outputGroupID, let outputReplacesGroup, let outputMetadata) = try await iterator.next()
- else {
- Issue.record("Expected following reasoning to close the active command output.")
- return
+ mutating func next() async throws -> CodexReviewBackendModel.Review.Event? {
+ try await mailbox.next()
}
- #expect(outputKind == .commandOutput)
- #expect(outputText == "diff output\n")
- #expect(outputGroupID == "cmd-1")
- #expect(outputReplacesGroup == true)
- #expect(outputMetadata?.status == "completed")
- #expect(outputMetadata?.itemID == "cmd-1")
- #expect(outputMetadata?.completedAt != nil)
- #expect(try await iterator.next() == .logEntry(
- kind: .reasoningSummary,
- text: "Inspecting diffs",
- groupID: "reasoning-1:summary:0",
- replacesGroup: false
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .agentMessage,
- text: "final review text",
- groupID: "review-item-1",
- replacesGroup: true
- ))
- #expect(try await iterator.next() == .completed(summary: "Succeeded.", result: "final review text"))
- #expect(try await iterator.next() == nil)
- }
-
- @Test func backendIgnoresEmptyCommandTerminalInteractionPolls() async throws {
- let run = CodexReviewBackendModel.Review.Run(threadID: "thread-1", turnID: "turn-1")
- let transport = FakeJSONRPCTransport()
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let events = await eventSequence(backend, run)
-
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "commandExecution", id: "cmd-1", command: "git diff")
- )
- )
- try await transport.emitServerNotification(
- method: "item/commandExecution/terminalInteraction",
- params: TestTerminalInteractionNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- itemID: "cmd-1",
- processID: "123",
- stdin: ""
- )
- )
- try await transport.emitServerNotification(
- method: "item/completed",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "commandExecution", id: "cmd-1", command: "git diff")
- )
- )
-
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .logEntry(
- kind: .command,
- text: "$ git diff",
- groupID: "cmd-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "commandExecution",
- status: "inProgress",
- itemID: "cmd-1",
- command: "git diff",
- commandStatus: "inProgress"
- )
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .command,
- text: "$ git diff",
- groupID: "cmd-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "commandExecution",
- status: "completed",
- itemID: "cmd-1",
- command: "git diff",
- commandStatus: "completed"
- )
- ))
}
- @Test func backendCarriesRichToolAndFileMetadata() async throws {
- let run = CodexReviewBackendModel.Review.Run(threadID: "thread-1", turnID: "turn-1")
- let transport = FakeJSONRPCTransport()
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let events = await eventSequence(backend, run)
-
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "webSearch", id: "web-1", query: "TextKit 2 markdown")
- )
- )
- try await transport.emitServerNotification(
- method: "item/completed",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "imageView", id: "image-1", status: "completed", path: "/tmp/screenshot.png")
- )
- )
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "fileChange", id: "file-1", path: "Sources/App.swift")
- )
- )
- try await transport.emitServerNotification(
- method: "item/fileChange/patchUpdated",
- params: TestMessageNotification(threadID: "thread-1", turnID: "turn-1", itemID: "file-1", message: "patch")
- )
- try await transport.emitServerNotification(
- method: "item/completed",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "hookPrompt", id: "hook-1", status: "completed", prompt: "Allow command?")
- )
- )
+ var mailbox: BackendReviewEventMailbox
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .logEntry(
- kind: .toolCall,
- text: "Web search: TextKit 2 markdown",
- groupID: "web-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "webSearch",
- title: "Web search",
- status: "started",
- query: "TextKit 2 markdown"
- )
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .toolCall,
- text: "Image viewed: /tmp/screenshot.png.",
- groupID: "image-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "imageView",
- title: "Image view",
- status: "completed",
- path: "/tmp/screenshot.png"
- )
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .toolCall,
- text: "Applying file changes.",
- groupID: "file-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "fileChange",
- title: "File changes",
- status: "started",
- path: "Sources/App.swift"
- )
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .toolCall,
- text: "File changes updated.",
- groupID: "file-1",
- replacesGroup: false,
- metadata: .init(sourceType: "fileChange", title: "File changes", status: "updated")
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .event,
- text: "Hook prompt completed.",
- groupID: "hook-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "hookPrompt",
- title: "Hook prompt",
- status: "completed",
- detail: "Allow command?"
- )
- ))
+ func makeAsyncIterator() -> AsyncIterator {
+ AsyncIterator(mailbox: mailbox)
}
+}
- @Test func backendMarksErroredToolCompletionsAsFailedMetadata() async throws {
- let run = CodexReviewBackendModel.Review.Run(threadID: "thread-1", turnID: "turn-1")
- let transport = FakeJSONRPCTransport()
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let events = await eventSequence(backend, run)
-
- try await transport.emitServerNotification(
- method: "item/completed",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(
- type: "mcpToolCall",
- id: "tool-error",
- server: "codex_review",
- tool: "review_read",
- error: "denied"
- )
- )
- )
- try await transport.emitServerNotification(
- method: "item/completed",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(
- type: "dynamicToolCall",
- id: "tool-success-false",
- namespace: "web",
- tool: "search",
- result: "no matches",
- success: false
- )
- )
- )
- try await transport.emitServerNotification(
+@Suite("AppServerClientTests")
+struct AppServerClientTests {
+ @Test func backendStartsReviewThroughExternalCodexKit() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ try await runtime.transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5")
+ try await runtime.transport.enqueueReviewStart(turnID: "turn-1", reviewThreadID: "thread-1")
+ let backend = AppServerCodexReviewBackend(appServer: runtime.server)
+
+ let attempt = try await backend.startReview(makeReviewStart(target: .uncommittedChanges))
+
+ #expect(attempt.run.threadID == "thread-1")
+ #expect(attempt.run.turnID == "turn-1")
+ #expect(attempt.run.reviewThreadID == "thread-1")
+
+ let requests = await runtime.transport.recordedRequests()
+ #expect(requests.map(\.method) == ["initialize", "thread/start", "review/start"])
+
+ let threadStart = try #require(requests.first { $0.method == "thread/start" })
+ let threadParams = try jsonObject(from: threadStart.params)
+ #expect(threadParams["cwd"] as? String == "/tmp/project")
+ #expect(threadParams["model"] as? String == "gpt-5")
+ #expect(threadParams["ephemeral"] as? Bool == false)
+ #expect(threadParams["approvalPolicy"] as? String == "never")
+ #expect(threadParams["permissions"] as? String == ":danger-full-access")
+ #expect(threadParams["sessionStartSource"] as? String == "startup")
+ #expect(threadParams["threadSource"] as? String == "user")
+ #expect(threadParams["sandbox"] == nil)
+
+ let reviewStart = try #require(requests.first { $0.method == "review/start" })
+ let reviewParams = try jsonObject(from: reviewStart.params)
+ #expect(reviewParams["threadId"] as? String == "thread-1")
+ let target = try #require(reviewParams["target"] as? [String: Any])
+ #expect(target["type"] as? String == "uncommittedChanges")
+ }
+
+ @Test func backendConsumesTypedReviewSessionStream() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ try await runtime.transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5")
+ try await runtime.transport.enqueueReviewStart(turnID: "turn-1", reviewThreadID: "review-thread")
+ await runtime.transport.waitForNotificationStreamCount(1)
+ let backend = AppServerCodexReviewBackend(appServer: runtime.server)
+
+ let attempt = try await backend.startReview(makeReviewStart(target: .baseBranch("main")))
+ var iterator = eventSequence(attempt).makeAsyncIterator()
+
+ try await runtime.transport.emitServerNotification(
method: "item/completed",
params: TestItemNotification(
- threadID: "thread-1",
+ threadID: "review-thread",
turnID: "turn-1",
item: .init(
- type: "webSearch",
- id: "search-result",
- status: "completed",
- query: "ReviewTimeline",
- result: "search result"
+ type: "commandExecution",
+ id: "cmd-1",
+ command: "swift test",
+ aggregatedOutput: "passed",
+ status: "completed"
)
)
)
-
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .logEntry(
- kind: .toolCall,
- text: "denied",
- groupID: "tool-error",
- replacesGroup: true,
- metadata: .init(
- sourceType: "mcpToolCall",
- title: "codex_review.review_read",
- status: "failed",
- server: "codex_review",
- tool: "review_read",
- errorText: "denied"
- )
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .toolCall,
- text: "no matches",
- groupID: "tool-success-false",
- replacesGroup: true,
- metadata: .init(
- sourceType: "dynamicToolCall",
- title: "web.search",
- status: "failed",
- namespace: "web",
- tool: "search",
- resultText: "no matches"
- )
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .toolCall,
- text: "search result",
- groupID: "search-result",
- replacesGroup: true,
- metadata: .init(
- sourceType: "webSearch",
- title: "Web search",
- status: "completed",
- query: "ReviewTimeline",
- resultText: "search result"
- )
- ))
- }
-
- @Test func backendWaitsForFinalReviewItemAfterTurnCompletes() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
-
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
- let events = await eventSequence(backend, run)
-
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "thread-1",
+ try await runtime.transport.emitServerNotification(
+ method: "item/agentMessage/delta",
+ params: TestDeltaNotification(
+ threadID: "review-thread",
turnID: "turn-1",
- item: .init(type: "enteredReviewMode", id: "review-item-1", review: "current changes")
+ itemID: "msg-1",
+ delta: "Looks good."
)
)
- try await transport.emitServerNotification(
+ try await runtime.transport.emitServerNotification(
method: "turn/completed",
- params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "turn-1", status: "completed"))
- )
- try await transport.emitServerNotification(
- method: "item/completed",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "exitedReviewMode", id: "review-item-1", review: "final review text")
+ params: TestTurnNotification(
+ threadID: "review-thread",
+ turn: .init(
+ id: "turn-1",
+ status: "completed",
+ items: [
+ .finalAnswer(id: "msg-1", text: "Looks good.")
+ ]
+ )
)
)
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .logEntry(
- kind: .progress,
- text: "Reviewing current changes",
- groupID: "review-item-1",
- replacesGroup: true
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .agentMessage,
- text: "final review text",
- groupID: "review-item-1",
- replacesGroup: true
- ))
- #expect(try await iterator.next() == .completed(summary: "Succeeded.", result: "final review text"))
+ #expect(
+ try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5"))
+ #expect(try await iterator.next() == .completed(finalReview: "Looks good."))
}
- @Test func backendReplaysBufferedReviewLifecycleNotifications() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
- try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
+ @Test func backendIgnoresAgentMessageDeltasInLifecycleStream() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ try await runtime.transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5")
+ try await runtime.transport.enqueueReviewStart(turnID: "turn-1", reviewThreadID: "review-thread-1")
+ await runtime.transport.waitForNotificationStreamCount(1)
+ let backend = AppServerCodexReviewBackend(appServer: runtime.server)
- let run = try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- ))
+ let firstAttempt = try await backend.startReview(makeReviewStart(runID: "run-1", sessionID: "session-1"))
+ try await runtime.transport.enqueueThreadStart(threadID: "thread-2", model: "gpt-5")
+ try await runtime.transport.enqueueReviewStart(turnID: "turn-2", reviewThreadID: "review-thread-2")
+ let secondAttempt = try await backend.startReview(makeReviewStart(runID: "run-2", sessionID: "session-2"))
- try await transport.emitServerNotification(
- method: "item/started",
- params: TestItemNotification(
- threadID: "thread-1",
+ try await runtime.transport.emitServerNotification(
+ method: "item/agentMessage/delta",
+ params: TestDeltaNotification(
+ threadID: "review-thread-1",
turnID: "turn-1",
- item: .init(type: "enteredReviewMode", id: "review-item-1", review: "current changes")
+ itemID: "msg-1",
+ delta: "first"
)
)
- try await transport.emitServerNotification(
- method: "item/completed",
- params: TestItemNotification(
- threadID: "thread-1",
- turnID: "turn-1",
- item: .init(type: "exitedReviewMode", id: "review-item-1", review: "final review text")
+ try await runtime.transport.emitServerNotification(
+ method: "item/agentMessage/delta",
+ params: TestDeltaNotification(
+ threadID: "review-thread-2",
+ turnID: "turn-2",
+ itemID: "msg-1",
+ delta: "second"
)
)
- try await transport.emitServerNotification(
+ try await runtime.transport.emitServerNotification(
method: "turn/completed",
- params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "turn-1", status: "completed"))
+ params: TestTurnNotification(
+ threadID: "review-thread-1",
+ turn: .init(
+ id: "turn-1",
+ status: "completed",
+ items: [
+ .finalAnswer(id: "msg-1", text: "first")
+ ]
+ )
+ )
)
-
- let events = await eventSequence(backend, run)
- var iterator = events.makeAsyncIterator()
- #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: nil))
- #expect(try await iterator.next() == .logEntry(
- kind: .progress,
- text: "Reviewing current changes",
- groupID: "review-item-1",
- replacesGroup: true
- ))
- #expect(try await iterator.next() == .logEntry(
- kind: .agentMessage,
- text: "final review text",
- groupID: "review-item-1",
- replacesGroup: true
- ))
- #expect(try await iterator.next() == .completed(summary: "Succeeded.", result: "final review text"))
- #expect(try await iterator.next() == nil)
- }
-
- @Test func backendMapsTerminalFailureAndCancellationNotifications() async throws {
- let failedRun = CodexReviewBackendModel.Review.Run(threadID: "thread-1", turnID: "turn-1")
- let failedTransport = FakeJSONRPCTransport()
- let failedBackend = AppServerCodexReviewBackend(client: .init(transport: failedTransport))
- let failedEvents = await eventSequence(failedBackend, failedRun)
-
- try await failedTransport.emitServerNotification(
+ try await runtime.transport.emitServerNotification(
method: "turn/completed",
params: TestTurnNotification(
- threadID: "thread-1",
- turn: .init(id: "turn-1", status: "failed", error: .init(message: "Review failed"))
+ threadID: "review-thread-2",
+ turn: .init(
+ id: "turn-2",
+ status: "completed",
+ items: [
+ .finalAnswer(id: "msg-1", text: "second")
+ ]
+ )
)
)
- var failedIterator = failedEvents.makeAsyncIterator()
- #expect(try await failedIterator.next() == .failed("Review failed"))
+ var firstIterator = eventSequence(firstAttempt).makeAsyncIterator()
+ #expect(
+ try await firstIterator.next()
+ == .started(turnID: "turn-1", reviewThreadID: "review-thread-1", model: "gpt-5"))
+ #expect(try await firstIterator.next() == .completed(finalReview: "first"))
+
+ var secondIterator = eventSequence(secondAttempt).makeAsyncIterator()
+ #expect(
+ try await secondIterator.next()
+ == .started(turnID: "turn-2", reviewThreadID: "review-thread-2", model: "gpt-5"))
+ #expect(try await secondIterator.next() == .completed(finalReview: "second"))
+ }
+
+ @Test func backendKeepsCommandOutputDeltasInCodexChat() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ try await runtime.transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5")
+ try await runtime.transport.enqueueReviewStart(turnID: "turn-1", reviewThreadID: "review-thread")
+ await runtime.transport.waitForNotificationStreamCount(1)
+ let backend = AppServerCodexReviewBackend(appServer: runtime.server)
- let cancelledRun = CodexReviewBackendModel.Review.Run(threadID: "thread-2", turnID: "turn-2")
- let cancelledTransport = FakeJSONRPCTransport()
- let cancelledBackend = AppServerCodexReviewBackend(client: .init(transport: cancelledTransport))
- let cancelledEvents = await eventSequence(cancelledBackend, cancelledRun)
+ let attempt = try await backend.startReview(makeReviewStart())
+ #expect(
+ try await nextEvent(from: attempt.events)
+ == .started(
+ turnID: "turn-1",
+ reviewThreadID: "review-thread",
+ model: "gpt-5"
+ ))
- try await cancelledTransport.emitServerNotification(
+ try await runtime.transport.emitServerNotification(
+ method: "item/commandExecution/outputDelta",
+ params: TestDeltaNotification(
+ threadID: "review-thread",
+ turnID: "turn-1",
+ itemID: "cmd-1",
+ delta: "first"
+ )
+ )
+ try await runtime.transport.emitServerNotification(
+ method: "item/commandExecution/outputDelta",
+ params: TestDeltaNotification(
+ threadID: "review-thread",
+ turnID: "turn-1",
+ itemID: "cmd-1",
+ delta: "second"
+ )
+ )
+ try await runtime.transport.emitServerNotification(
method: "turn/completed",
params: TestTurnNotification(
- threadID: "thread-2",
- turn: .init(id: "turn-2", status: "interrupted", error: .init(message: "Stopped"))
+ threadID: "review-thread",
+ turn: .init(
+ id: "turn-1",
+ status: "completed",
+ items: [
+ .finalAnswer(id: "msg-1", text: "No issues found.")
+ ]
+ )
)
)
- var cancelledIterator = cancelledEvents.makeAsyncIterator()
- #expect(try await cancelledIterator.next() == .cancelled("Stopped"))
+ #expect(try await nextEvent(from: attempt.events) == .completed(finalReview: "No issues found."))
+ }
+
+ @Test func cleanupDefersReviewThreadDeletionUntilShutdown() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ try await runtime.transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5")
+ try await runtime.transport.enqueueReviewStart(turnID: "turn-1", reviewThreadID: "review-thread")
+ try await runtime.transport.enqueueEmpty(for: "thread/delete")
+ try await runtime.transport.enqueueEmpty(for: "thread/delete")
+ let backend = AppServerCodexReviewBackend(appServer: runtime.server)
- let failedWithoutMessageRun = CodexReviewBackendModel.Review.Run(threadID: "thread-4", turnID: "turn-4")
- let failedWithoutMessageTransport = FakeJSONRPCTransport()
- let failedWithoutMessageBackend = AppServerCodexReviewBackend(client: .init(transport: failedWithoutMessageTransport))
- let failedWithoutMessageEvents = await eventSequence(failedWithoutMessageBackend, failedWithoutMessageRun)
+ let attempt = try await backend.startReview(makeReviewStart())
+ await backend.cleanupReview(attempt.run)
- try await failedWithoutMessageTransport.emitServerNotification(
- method: "turn/completed",
- params: TestPartialTurnNotification(
- threadID: "thread-4",
- turn: .init(id: "turn-4", status: "failed", error: .init())
- )
- )
+ // Completed review chats stay readable until runtime teardown.
+ #expect(await runtime.transport.recordedRequests(method: "thread/delete").isEmpty)
- var failedWithoutMessageIterator = failedWithoutMessageEvents.makeAsyncIterator()
- #expect(try await failedWithoutMessageIterator.next() == .failed("Failed."))
+ await backend.cleanupActiveReviewsForShutdown(
+ .init(reason: .init(message: "Review runtime stopped."), recoveryWaitingRuns: [])
+ )
- let cancelledWithoutMessageRun = CodexReviewBackendModel.Review.Run(threadID: "thread-5", turnID: "turn-5")
- let cancelledWithoutMessageTransport = FakeJSONRPCTransport()
- let cancelledWithoutMessageBackend = AppServerCodexReviewBackend(client: .init(transport: cancelledWithoutMessageTransport))
- let cancelledWithoutMessageEvents = await eventSequence(cancelledWithoutMessageBackend, cancelledWithoutMessageRun)
+ let deleteRequests = await runtime.transport.recordedRequests(method: "thread/delete")
+ #expect(deleteRequests.count == 2)
+ let deletedIDs = try deleteRequests.map { try jsonObject(from: $0.params)["threadId"] as? String }
+ #expect(Set(deletedIDs.compactMap { $0 }) == ["review-thread", "thread-1"])
+ }
- try await cancelledWithoutMessageTransport.emitServerNotification(
- method: "turn/completed",
- params: TestPartialTurnNotification(
- threadID: "thread-5",
- turn: .init(id: "turn-5", status: "interrupted", error: .init())
- )
+ @Test func shutdownCleanupDeletesRecoveryWaitingRunsWithoutInterrupt() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ try await runtime.transport.enqueueEmpty(for: "thread/delete")
+ try await runtime.transport.enqueueEmpty(for: "thread/delete")
+ let backend = AppServerCodexReviewBackend(appServer: runtime.server)
+ let run = CodexReviewBackendModel.Review.Run(
+ attemptID: "attempt-recovery",
+ threadID: "thread-1",
+ turnID: "turn-1",
+ reviewThreadID: "review-thread",
+ model: "gpt-5"
+ )
+ let request = CodexReviewRuntimeStopReviewCleanupRequest(
+ reason: .init(message: "Review runtime stopped."),
+ recoveryWaitingRuns: [run]
)
- var cancelledWithoutMessageIterator = cancelledWithoutMessageEvents.makeAsyncIterator()
- #expect(try await cancelledWithoutMessageIterator.next() == .cancelled("Cancellation requested."))
+ await backend.cleanupActiveReviewsForShutdown(request)
- let retryingRun = CodexReviewBackendModel.Review.Run(threadID: "thread-3", turnID: "turn-3")
- let retryingTransport = FakeJSONRPCTransport()
- let retryingBackend = AppServerCodexReviewBackend(client: .init(transport: retryingTransport))
- let retryingEvents = await eventSequence(retryingBackend, retryingRun)
+ let requests = await runtime.transport.recordedRequests()
+ #expect(requests.map(\.method).contains("turn/interrupt") == false)
+ let deleteRequests = requests.filter { $0.method == "thread/delete" }
+ #expect(deleteRequests.count == 2)
+ let deletedIDs = try deleteRequests.map { try jsonObject(from: $0.params)["threadId"] as? String }
+ #expect(Set(deletedIDs.compactMap { $0 }) == ["review-thread", "thread-1"])
+ }
- try await retryingTransport.emitServerNotification(
- method: "error",
- params: TestErrorNotification(threadID: "thread-3", turnID: "turn-3", message: "Retrying request", willRetry: true)
+ @Test func interruptReviewCancelsReconstructedRunThroughResumedThread() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ try await runtime.transport.enqueueJSON(
+ #"{"thread":{"id":"thread-1"},"model":"gpt-5"}"#,
+ for: "thread/resume"
)
- try await retryingTransport.emitServerNotification(
- method: "turn/completed",
- params: TestTurnNotification(threadID: "thread-3", turn: .init(id: "turn-3", status: "completed"))
+ try await runtime.transport.enqueueEmpty(for: "turn/interrupt")
+ let backend = AppServerCodexReviewBackend(appServer: runtime.server)
+ let run = CodexReviewBackendModel.Review.Run(
+ attemptID: "attempt-1",
+ threadID: "thread-1",
+ turnID: "turn-1",
+ reviewThreadID: "thread-1",
+ model: "gpt-5"
)
- var retryingIterator = retryingEvents.makeAsyncIterator()
- #expect(try await retryingIterator.next() == .started(turnID: "turn-3", reviewThreadID: "thread-3", model: nil))
- #expect(try await retryingIterator.next() == .logEntry(
- kind: .progress,
- text: "Retrying request",
- groupID: "turn-3",
- replacesGroup: false
- ))
- #expect(try await retryingIterator.next() == .completed(summary: "Succeeded.", result: nil))
+ try await backend.interruptReview(run, reason: .init(message: "Stop"))
+
+ let requests = await runtime.transport.recordedRequests()
+ #expect(requests.map(\.method) == ["initialize", "thread/resume", "turn/interrupt"])
+ let resume = try #require(requests.first { $0.method == "thread/resume" })
+ let resumeParams = try jsonObject(from: resume.params)
+ #expect(resumeParams["threadId"] as? String == "thread-1")
+ #expect(resumeParams["model"] as? String == "gpt-5")
+ let interrupt = try #require(requests.first { $0.method == "turn/interrupt" })
+ let interruptParams = try jsonObject(from: interrupt.params)
+ #expect(interruptParams["threadId"] as? String == "thread-1")
+ #expect(interruptParams["turnId"] as? String == "turn-1")
}
- @Test func backendIgnoresUnrelatedNotificationsBeforeReviewPayloadDecode() async throws {
- let transport = FakeJSONRPCTransport()
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
- let run = CodexReviewBackendModel.Review.Run(threadID: "thread-1", turnID: "turn-1")
- let events = await eventSequence(backend, run)
+ @Test func interruptReviewCancelsDetachedReconstructedRunThroughReviewThread() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ try await runtime.transport.enqueueJSON(
+ #"{"thread":{"id":"review-thread"},"model":"gpt-5"}"#,
+ for: "thread/resume"
+ )
+ try await runtime.transport.enqueueEmpty(for: "turn/interrupt")
+ let backend = AppServerCodexReviewBackend(appServer: runtime.server)
+ let run = CodexReviewBackendModel.Review.Run(
+ attemptID: "attempt-1",
+ threadID: "thread-1",
+ turnID: "turn-1",
+ reviewThreadID: "review-thread",
+ model: "gpt-5"
+ )
+
+ try await backend.interruptReview(run, reason: .init(message: "Stop"))
- try await transport.emitServerNotification(
- method: "account/updated",
- params: ["accountID": "account-1"]
+ let requests = await runtime.transport.recordedRequests()
+ #expect(requests.map(\.method) == ["initialize", "thread/resume", "turn/interrupt"])
+ let resume = try #require(requests.first { $0.method == "thread/resume" })
+ let resumeParams = try jsonObject(from: resume.params)
+ #expect(resumeParams["threadId"] as? String == "review-thread")
+ #expect(resumeParams["model"] as? String == "gpt-5")
+ let interrupt = try #require(requests.first { $0.method == "turn/interrupt" })
+ let interruptParams = try jsonObject(from: interrupt.params)
+ #expect(interruptParams["threadId"] as? String == "review-thread")
+ #expect(interruptParams["turnId"] as? String == "turn-1")
+ }
+
+ @Test func preparedReviewRestartCancelsRollsBackAndRestartsOnSameThread() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ try await runtime.transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5")
+ try await runtime.transport.enqueueReviewStart(turnID: "turn-old", reviewThreadID: "review-thread")
+ await runtime.transport.waitForNotificationStreamCount(1)
+ let backend = AppServerCodexReviewBackend(appServer: runtime.server)
+ let attempt = try await backend.startReview(makeReviewStart())
+ try await runtime.transport.enqueueJSON(
+ #"{"thread":{"id":"review-thread"},"model":"gpt-5"}"#,
+ for: "thread/resume"
+ )
+ await runtime.transport.enqueueFailure(
+ code: -32602,
+ message: "expected active turn id turn-old but found turn-new",
+ for: "turn/interrupt"
)
- try await transport.emitServerNotification(
- method: "turn/completed",
- params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "turn-1", status: "completed"))
+ try await runtime.transport.enqueueEmpty(for: "turn/interrupt")
+ try await runtime.transport.enqueueJSON(
+ #"{"thread":{"id":"review-thread"},"model":"gpt-5"}"#,
+ for: "thread/resume"
)
+ try await runtime.transport.enqueueEmpty(for: "thread/rollback")
+ try await runtime.transport.enqueueJSON(
+ #"{"thread":{"id":"thread-1"},"model":"gpt-5"}"#,
+ for: "thread/resume"
+ )
+ try await runtime.transport.enqueueReviewStart(turnID: "turn-restarted", reviewThreadID: "review-thread")
- var iterator = events.makeAsyncIterator()
- let event = try await iterator.next()
- #expect(event == .completed(summary: "Succeeded.", result: nil))
- }
+ let prepareTask = Task {
+ try await backend.prepareReviewRestart(attempt.run)
+ }
+ defer {
+ prepareTask.cancel()
+ }
+ await runtime.transport.waitForRequest(method: "turn/interrupt", count: 2)
+ try await runtime.transport.emitServerNotification(
+ method: "turn/completed",
+ params: TestTurnNotification(
+ threadID: "review-thread",
+ turn: .init(id: "turn-new", status: "interrupted")
+ )
+ )
+ let token = try await withTimeout {
+ try await prepareTask.value
+ }
+ let restartedAttempt = try await backend.restartPreparedReview(token, request: makeReviewStart())
+
+ #expect(token.interruptedRun == attempt.run)
+ #expect(restartedAttempt.run.threadID == "thread-1")
+ #expect(restartedAttempt.run.turnID == "turn-restarted")
+ #expect(restartedAttempt.run.reviewThreadID == "review-thread")
+ let requests = await runtime.transport.recordedRequests()
+ #expect(
+ requests.map(\.method) == [
+ "initialize",
+ "thread/start",
+ "review/start",
+ "thread/resume",
+ "turn/interrupt",
+ "turn/interrupt",
+ "thread/resume",
+ "thread/rollback",
+ "thread/resume",
+ "review/start",
+ ])
+ let resumeThreadIDs = try requests.filter { $0.method == "thread/resume" }.map {
+ try jsonObject(from: $0.params)["threadId"] as? String
+ }
+ #expect(resumeThreadIDs == ["review-thread", "review-thread", "thread-1"])
+ let resumeModels = try requests.filter { $0.method == "thread/resume" }.map {
+ try jsonObject(from: $0.params)["model"] as? String
+ }
+ #expect(resumeModels == ["gpt-5", "gpt-5", "gpt-5"])
+ // Restarted reviews keep the review thread profile instead of default
+ // Codex settings.
+ let resumeApprovalPolicies = try requests.filter { $0.method == "thread/resume" }.map {
+ try jsonObject(from: $0.params)["approvalPolicy"] as? String
+ }
+ #expect(resumeApprovalPolicies.last == "never")
+ let interruptTurnIDs = try requests.filter { $0.method == "turn/interrupt" }.map {
+ try jsonObject(from: $0.params)["turnId"] as? String
+ }
+ #expect(interruptTurnIDs == ["turn-old", "turn-new"])
+ let rollback = try #require(requests.first { $0.method == "thread/rollback" })
+ let rollbackParams = try jsonObject(from: rollback.params)
+ #expect(rollbackParams["threadId"] as? String == "review-thread")
+ #expect(rollbackParams["numTurns"] as? Int == 1)
+ let reviewStarts = requests.filter { $0.method == "review/start" }
+ let restart = try #require(reviewStarts.last)
+ let restartParams = try jsonObject(from: restart.params)
+ #expect(restartParams["threadId"] as? String == "thread-1")
+ }
+
+ @Test func shutdownCleanupDoesNotDeleteProvisionalRestartSourceThread() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ try await runtime.transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5")
+ try await runtime.transport.enqueueReviewStart(turnID: "turn-old", reviewThreadID: "thread-1")
+ await runtime.transport.waitForNotificationStreamCount(1)
+ let backend = AppServerCodexReviewBackend(appServer: runtime.server)
+ let attempt = try await backend.startReview(makeReviewStart())
+ try await runtime.transport.enqueueJSON(
+ #"{"thread":{"id":"thread-1"},"model":"gpt-5"}"#,
+ for: "thread/resume"
+ )
+ try await runtime.transport.enqueueEmpty(for: "turn/interrupt")
+ let prepareTask = Task {
+ try await backend.prepareReviewRestart(attempt.run)
+ }
+ defer {
+ prepareTask.cancel()
+ }
+ await runtime.transport.waitForRequest(method: "turn/interrupt")
+ try await runtime.transport.emitServerNotification(
+ method: "turn/completed",
+ params: TestTurnNotification(
+ threadID: "thread-1",
+ turn: .init(id: "turn-old", status: "interrupted")
+ )
+ )
+ let token = try await withTimeout {
+ try await prepareTask.value
+ }
- @Test func backendCleansThreadWhenReviewStartFailsAfterThreadStart() async throws {
- let transport = FakeJSONRPCTransport()
- try await enqueueInitialize(transport)
- try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1"), for: "thread/start")
- await transport.enqueueFailure(.responseError(code: -32602, message: "invalid target"), for: "review/start")
- let backend = AppServerCodexReviewBackend(client: .init(transport: transport))
+ let reviewStartGate = CodexAppServerTestGate()
+ try await runtime.transport.enqueueJSON(
+ #"{"thread":{"id":"thread-1"},"model":"gpt-5"}"#,
+ for: "thread/resume"
+ )
+ try await runtime.transport.enqueueEmpty(for: "thread/rollback")
+ try await runtime.transport.enqueueJSON(
+ #"{"thread":{"id":"thread-1"},"model":"gpt-5"}"#,
+ for: "thread/resume"
+ )
+ try await runtime.transport.enqueueReviewStart(turnID: "turn-restarted", reviewThreadID: "thread-1")
+ try await runtime.transport.enqueueEmpty(for: "thread/delete")
+ await runtime.transport.holdNextIgnoringCancellation(
+ method: "review/start",
+ gate: reviewStartGate
+ )
+ let restartTask = Task {
+ try await backend.restartPreparedReview(token, request: makeReviewStart())
+ }
+ defer {
+ restartTask.cancel()
+ }
+ await runtime.transport.waitForRequest(method: "review/start", count: 2)
- await #expect(throws: JSONRPC.Error.responseError(code: -32602, message: "invalid target")) {
- try await backend.startReview(.init(
- jobID: "job-1",
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
+ await backend.cleanupActiveReviewsForShutdown(
+ .init(
+ reason: .init(message: "Review runtime stopped."),
+ recoveryWaitingRuns: [attempt.run]
))
- }
- let methods = await transport.recordedRequests().map(\.method)
- #expect(methods == [
- "initialize",
- "thread/start",
- "review/start",
- "thread/backgroundTerminals/clean",
- "thread/unsubscribe",
- "thread/delete",
- ])
+ #expect(await runtime.transport.recordedRequests(method: "thread/delete").isEmpty)
+ await reviewStartGate.open()
+ let restartedAttempt = try await withTimeout {
+ try await restartTask.value
+ }
+ #expect(restartedAttempt.run.threadID == "thread-1")
+ #expect(restartedAttempt.run.turnID == "turn-restarted")
}
+}
- private func waitUntil(timeout: Duration = .seconds(2), condition: () async -> Bool) async -> Bool {
- let clock = ContinuousClock()
- let deadline = clock.now + timeout
- while await condition() == false {
- if clock.now >= deadline {
- return false
- }
- try? await Task.sleep(for: .milliseconds(50))
+private enum AppServerClientTestTimeout: Error {
+ case timedOut
+}
+
+private func withTimeout(
+ timeout: Duration = .seconds(1),
+ operation: @escaping @Sendable () async throws -> T
+) async throws -> T {
+ try await withThrowingTaskGroup(of: T.self) { group in
+ group.addTask {
+ try await operation()
}
- return true
+ group.addTask {
+ try await Task.sleep(for: timeout)
+ throw AppServerClientTestTimeout.timedOut
+ }
+ let result = try #require(await group.next())
+ group.cancelAll()
+ return result
}
+}
- private func waitUntil(timeout: Duration, condition: () -> Bool) async -> Bool {
- let clock = ContinuousClock()
- let deadline = clock.now + timeout
- while condition() == false {
- if clock.now >= deadline {
- return false
- }
- try? await Task.sleep(for: .milliseconds(50))
+private func nextEvent(
+ from mailbox: BackendReviewEventMailbox,
+ timeout: Duration = .seconds(1)
+) async throws -> CodexReviewBackendModel.Review.Event? {
+ try await withThrowingTaskGroup(of: CodexReviewBackendModel.Review.Event?.self) { group in
+ group.addTask {
+ try await mailbox.next()
}
- return true
+ group.addTask {
+ try await Task.sleep(for: timeout)
+ throw AppServerClientTestTimeout.timedOut
+ }
+ let event = try await group.next()
+ group.cancelAll()
+ return event ?? nil
}
}
-private func enqueueInitialize(_ transport: FakeJSONRPCTransport) async throws {
- try await transport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
+private func eventSequence(
+ _ attempt: BackendReviewAttempt
+) -> BackendReviewEventSequence {
+ BackendReviewEventSequence(mailbox: attempt.events)
}
-private func makeModelCatalogItem(
- model: String,
- isDefault: Bool = false
-) -> CodexReviewSettings.ModelCatalogItem {
+private func makeReviewStart(
+ runID: String = "run-1",
+ sessionID: String = "session-1",
+ target: CodexReviewAPI.Target = .uncommittedChanges
+) -> CodexReviewBackendModel.Review.Start {
.init(
- id: model,
- model: model,
- displayName: model,
- hidden: false,
- supportedReasoningEfforts: [.init(reasoningEffort: .medium, description: "Balanced")],
- defaultReasoningEffort: .medium,
- supportedServiceTiers: [.fast],
- isDefault: isDefault
+ runID: runID,
+ sessionID: sessionID,
+ request: .init(cwd: "/tmp/project", target: target),
+ model: "gpt-5"
)
}
-private struct TestTurnNotification: Encodable, Sendable {
- var threadID: String
- var turn: AppServerAPI.Turn.Payload
- var reviewThreadID: String? = nil
- var result: String? = nil
-
- enum CodingKeys: String, CodingKey {
- case threadID = "threadId"
- case turn
- case reviewThreadID = "reviewThreadId"
- case result
- }
+private func jsonObject(from data: Data) throws -> [String: Any] {
+ try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
}
-private struct TestPartialTurnNotification: Encodable, Sendable {
+private struct TestTurnNotification: Encodable, Sendable {
var threadID: String
- var turn: TestPartialTurn
+ var turn: TestTurn
enum CodingKeys: String, CodingKey {
case threadID = "threadId"
@@ -5852,43 +576,15 @@ private struct TestPartialTurnNotification: Encodable, Sendable {
}
}
-private struct TestPartialTurn: Encodable, Sendable {
+private struct TestTurn: Encodable, Sendable {
var id: String
var status: String
- var error: TestPartialTurnError
-}
-
-private struct TestPartialTurnError: Encodable, Sendable {}
+ var items: [TestItem]?
-private struct TestThreadStatusNotification: Encodable, Sendable {
- var threadID: String
- var status: TestThreadStatus
-
- enum CodingKeys: String, CodingKey {
- case threadID = "threadId"
- case status
- }
-}
-
-private struct TestThreadStatus: Encodable, Sendable {
- var type: String
-}
-
-private struct TestThreadClosedNotification: Encodable, Sendable {
- var threadID: String
-
- enum CodingKeys: String, CodingKey {
- case threadID = "threadId"
- }
-}
-
-private struct TestContextCompactedNotification: Encodable, Sendable {
- var threadID: String
- var turnID: String
-
- enum CodingKeys: String, CodingKey {
- case threadID = "threadId"
- case turnID = "turnId"
+ init(id: String, status: String, items: [TestItem]? = nil) {
+ self.id = id
+ self.status = status
+ self.items = items
}
}
@@ -5897,133 +593,24 @@ private struct TestDeltaNotification: Encodable, Sendable {
var turnID: String
var itemID: String
var delta: String
- var summaryIndex: Int? = nil
- var contentIndex: Int? = nil
enum CodingKeys: String, CodingKey {
case threadID = "threadId"
case turnID = "turnId"
case itemID = "itemId"
case delta
- case summaryIndex
- case contentIndex
- }
-}
-
-private struct TestBase64OutputNotification: Encodable, Sendable {
- var threadID: String
- var turnID: String
- var itemID: String?
- var processID: String?
- var processHandle: String?
- var deltaBase64: String
-
- enum CodingKeys: String, CodingKey {
- case threadID = "threadId"
- case turnID = "turnId"
- case itemID = "itemId"
- case processID = "processId"
- case processHandle
- case deltaBase64
- }
-}
-
-private struct TestTerminalInteractionNotification: Encodable, Sendable {
- var threadID: String
- var turnID: String
- var itemID: String
- var processID: String
- var stdin: String
-
- enum CodingKeys: String, CodingKey {
- case threadID = "threadId"
- case turnID = "turnId"
- case itemID = "itemId"
- case processID = "processId"
- case stdin
- }
-}
-
-private struct TestPlanNotification: Encodable, Sendable {
- var threadID: String
- var turnID: String
- var plan: [Step]
-
- struct Step: Encodable, Sendable {
- var step: String
- var status: String
- }
-
- enum CodingKeys: String, CodingKey {
- case threadID = "threadId"
- case turnID = "turnId"
- case plan
}
}
private struct TestItemNotification: Encodable, Sendable {
var threadID: String
var turnID: String
- var itemID: String?
var item: TestItem
- var startedAtMs: Int64?
- var completedAtMs: Int64?
-
- init(
- threadID: String,
- turnID: String,
- itemID: String? = nil,
- item: TestItem,
- startedAtMs: Int64? = nil,
- completedAtMs: Int64? = nil
- ) {
- self.threadID = threadID
- self.turnID = turnID
- self.itemID = itemID
- self.item = item
- self.startedAtMs = startedAtMs
- self.completedAtMs = completedAtMs
- }
-
- enum CodingKeys: String, CodingKey {
- case threadID = "threadId"
- case turnID = "turnId"
- case itemID = "itemId"
- case item
- case startedAtMs
- case completedAtMs
- }
-}
-
-private struct TestCommandExecutionItemNotification: Encodable, Sendable {
- var threadID: String
- var turnID: String
- var item: Item
- var startedAtMs: Int64?
- var completedAtMs: Int64?
-
- struct Item: Encodable, Sendable {
- var type = "commandExecution"
- var id: String
- var command: String
- var processID: String?
- var exitCode: Int?
-
- enum CodingKeys: String, CodingKey {
- case type
- case id
- case command
- case processID = "processId"
- case exitCode
- }
- }
enum CodingKeys: String, CodingKey {
case threadID = "threadId"
case turnID = "turnId"
case item
- case startedAtMs
- case completedAtMs
}
}
@@ -6031,172 +618,45 @@ private struct TestItem: Encodable, Sendable {
var type: String
var id: String
var text: String?
+ var phase: String?
var review: String?
var command: String?
var cwd: String?
- var processID: String?
- var source: String?
var aggregatedOutput: String?
var exitCode: Int?
- var durationMs: Int?
- var commandActions: [TestCommandAction]?
var status: String?
- var namespace: String?
- var server: String?
- var tool: String?
- var query: String?
- var path: String?
- var result: String?
- var error: String?
- var success: Bool?
- var prompt: String?
- var summary: [String]?
- var content: [String]?
init(
type: String,
id: String,
text: String? = nil,
+ phase: String? = nil,
review: String? = nil,
command: String? = nil,
cwd: String? = nil,
- processID: String? = nil,
- source: String? = nil,
aggregatedOutput: String? = nil,
exitCode: Int? = nil,
- durationMs: Int? = nil,
- commandActions: [TestCommandAction]? = nil,
- status: String? = nil,
- namespace: String? = nil,
- server: String? = nil,
- tool: String? = nil,
- query: String? = nil,
- path: String? = nil,
- result: String? = nil,
- error: String? = nil,
- success: Bool? = nil,
- prompt: String? = nil,
- summary: [String]? = nil,
- content: [String]? = nil
+ status: String? = nil
) {
self.type = type
self.id = id
self.text = text
+ self.phase = phase
self.review = review
self.command = command
self.cwd = cwd
- self.processID = processID
- self.source = source
self.aggregatedOutput = aggregatedOutput
self.exitCode = exitCode
- self.durationMs = durationMs
- self.commandActions = commandActions
self.status = status
- self.namespace = namespace
- self.server = server
- self.tool = tool
- self.query = query
- self.path = path
- self.result = result
- self.error = error
- self.success = success
- self.prompt = prompt
- self.summary = summary
- self.content = content
- }
-}
-
-private struct TestCommandAction: Encodable, Sendable {
- var type: String
- var command: String
- var name: String?
- var path: String?
- var query: String?
-
- init(
- type: String,
- command: String,
- name: String? = nil,
- path: String? = nil,
- query: String? = nil
- ) {
- self.type = type
- self.command = command
- self.name = name
- self.path = path
- self.query = query
- }
-
- static func read(command: String, name: String, path: String) -> Self {
- .init(type: "read", command: command, name: name, path: path)
- }
-
- static func search(command: String, query: String?, path: String?) -> Self {
- .init(type: "search", command: command, path: path, query: query)
- }
-}
-
-private struct TestDiagnosticNotification: Encodable, Sendable {
- var summary: String
- var details: String?
-}
-
-private struct TestModelReroutedNotification: Encodable, Sendable {
- var threadID: String
- var turnID: String
- var fromModel: String
- var toModel: String
- var reason: String
-
- enum CodingKeys: String, CodingKey {
- case threadID = "threadId"
- case turnID = "turnId"
- case fromModel
- case toModel
- case reason
- }
-}
-
-private struct TestDiffNotification: Encodable, Sendable {
- var threadID: String
- var turnID: String
- var diff: String
-
- enum CodingKeys: String, CodingKey {
- case threadID = "threadId"
- case turnID = "turnId"
- case diff
- }
-}
-
-private struct TestMessageNotification: Encodable, Sendable {
- var threadID: String
- var turnID: String
- var itemID: String? = nil
- var message: String
-
- enum CodingKeys: String, CodingKey {
- case threadID = "threadId"
- case turnID = "turnId"
- case itemID = "itemId"
- case message
}
-}
-
-private struct TestGlobalMessageNotification: Encodable, Sendable {
- var message: String
-}
-
-private struct TestErrorNotification: Encodable, Sendable {
- var threadID: String? = nil
- var turnID: String? = nil
- var message: String
- var willRetry: Bool
- enum CodingKeys: String, CodingKey {
- case threadID = "threadId"
- case turnID = "turnId"
- case message
- case willRetry
+ static func finalAnswer(id: String, text: String) -> TestItem {
+ .init(
+ type: "agentMessage",
+ id: id,
+ text: text,
+ phase: "final_answer",
+ status: "completed"
+ )
}
}
diff --git a/Tests/CodexReviewAppServerWireTests/AppServerWireEventTests.swift b/Tests/CodexReviewAppServerWireTests/AppServerWireEventTests.swift
deleted file mode 100644
index 0b49c0c8..00000000
--- a/Tests/CodexReviewAppServerWireTests/AppServerWireEventTests.swift
+++ /dev/null
@@ -1,1422 +0,0 @@
-import Foundation
-import Testing
-@testable import CodexReviewAppServerWire
-import CodexReviewDomain
-
-@Suite("app-server wire events")
-struct AppServerWireEventTests {
- @Test func preservesUnknownNotificationRawPayloadAndEmitsUnknownContent() throws {
- let notification = try decodeNotification("""
- {
- "method": "future/event",
- "params": {
- "turnId": "turn-1",
- "itemId": "future-1",
- "nested": {
- "answer": 42
- },
- "items": [true, "raw"]
- }
- }
- """)
-
- #expect(notification.rawMethod == "future/event")
- #expect(notification.method.rawValue == "future/event")
- #expect(notification.rawPayload?.objectValue?["nested"] == .object(["answer": .int(42)]))
- #expect(notification.payload.rawFields["items"] == .array([.bool(true), .string("raw")]))
-
- let events = notification.domainEvents()
- guard case .itemUpdated(let seed) = try #require(events.first) else {
- Issue.record("expected unknown item update")
- return
- }
- #expect(seed.id.rawValue == "future-1")
- #expect(seed.kind.rawValue == "future/event")
- #expect(seed.family == .unknown)
- if case .unknown(let unknown) = seed.content {
- #expect(unknown.title == "future/event")
- #expect(unknown.detail?.contains("\"answer\":42") == true)
- } else {
- Issue.record("expected unknown content")
- }
-
- let turnScoped = try decodeNotification("""
- {
- "method": "future/turnScoped",
- "params": {
- "turnId": "turn-1",
- "value": "raw"
- }
- }
- """)
-
- guard case .itemUpdated(let turnScopedSeed) = try #require(turnScoped.domainEvents().first) else {
- Issue.record("expected turn-scoped unknown item update")
- return
- }
- #expect(turnScopedSeed.id.rawValue == "turn-1:future/turnScoped")
- }
-
- @Test func preservesNonObjectRawParams() throws {
- let arrayParams = try decodeNotification("""
- {
- "method": "future/positional",
- "params": ["alpha", 2]
- }
- """)
-
- #expect(arrayParams.rawPayload == .array([.string("alpha"), .int(2)]))
- #expect(arrayParams.payload.rawValue == .array([.string("alpha"), .int(2)]))
- guard case .itemUpdated(let arraySeed) = try #require(arrayParams.domainEvents().first) else {
- Issue.record("expected unknown event for array params")
- return
- }
- if case .unknown(let unknown) = arraySeed.content {
- #expect(unknown.detail == "[\"alpha\",2]")
- } else {
- Issue.record("expected unknown array content")
- }
-
- let scalarParams = try decodeNotification("""
- {
- "method": "future/scalar",
- "params": "raw"
- }
- """)
-
- #expect(scalarParams.rawPayload == .string("raw"))
- #expect(scalarParams.payload.rawValue == .string("raw"))
-
- let nullParams = try decodeNotification("""
- {
- "method": "future/null",
- "params": null
- }
- """)
-
- #expect(nullParams.rawPayload == .null)
- #expect(nullParams.payload.rawValue == .null)
- guard case .itemUpdated(let nullSeed) = try #require(nullParams.domainEvents().first) else {
- Issue.record("expected unknown event for null params")
- return
- }
- if case .unknown(let unknown) = nullSeed.content {
- #expect(unknown.detail == "null")
- } else {
- Issue.record("expected unknown null content")
- }
- }
-
- @Test func preservesUnknownItemKindRawValueAndRawFields() throws {
- let notification = try decodeNotification("""
- {
- "method": "item/started",
- "params": {
- "item": {
- "id": "future-1",
- "type": "futureTool",
- "futureFlag": true
- }
- }
- }
- """)
-
- #expect(notification.payload.item?.rawType == "futureTool")
- #expect(notification.payload.item?.rawFields["futureFlag"] == .bool(true))
-
- let events = notification.domainEvents()
- guard case .itemStarted(let seed) = try #require(events.first) else {
- Issue.record("expected started item")
- return
- }
- #expect(seed.kind.rawValue == "futureTool")
- #expect(seed.id.rawValue == "future-1")
- if case .unknown(let unknown) = seed.content {
- #expect(unknown.title == "futureTool")
- #expect(unknown.detail?.contains("\"futureFlag\":true") == true)
- } else {
- Issue.record("expected unknown content for future item")
- }
- }
-
- @Test func mapsOfficialItemNotificationsToStructuredContentWithoutDisplayText() throws {
- let turnStarted = try decodeNotification("""
- {
- "method": "turn/started",
- "params": {
- "threadId": "thread-1",
- "turn": {
- "id": "turn-1"
- },
- "model": "gpt-5"
- }
- }
- """)
-
- guard case .runStarted(let turnID, let reviewThreadID, let model) = try #require(turnStarted.domainEvents().first) else {
- Issue.record("expected turn start")
- return
- }
- #expect(turnID.rawValue == "turn-1")
- #expect(reviewThreadID?.rawValue == "thread-1")
- #expect(model == "gpt-5")
-
- let commandStarted = try decodeNotification("""
- {
- "method": "item/started",
- "params": {
- "startedAtMs": 1000,
- "item": {
- "id": "cmd-1",
- "type": "commandExecution",
- "command": "swift test",
- "cwd": "/tmp/project",
- "commandActions": [
- {
- "type": "read",
- "command": "cat Package.swift",
- "name": "Package.swift",
- "path": "Package.swift"
- },
- {
- "type": "search",
- "command": "rg ReviewTimeline",
- "query": "ReviewTimeline"
- }
- ]
- }
- }
- }
- """)
-
- guard case .itemStarted(let commandSeed) = try #require(commandStarted.domainEvents().first) else {
- Issue.record("expected command item start")
- return
- }
- #expect(commandSeed.family == .command)
- #expect(commandSeed.phase == .running)
- #expect(commandSeed.startedAt == Date(timeIntervalSince1970: 1))
- if case .command(let command) = commandSeed.content {
- #expect(command.command == "swift test")
- #expect(command.command.hasPrefix("$") == false)
- #expect(command.cwd == "/tmp/project")
- #expect(command.output.isEmpty)
- #expect(command.actions == [
- .init(
- kind: .read,
- command: "cat Package.swift",
- name: "Package.swift",
- path: "Package.swift"
- ),
- .init(
- kind: .search,
- command: "rg ReviewTimeline",
- query: "ReviewTimeline"
- )
- ])
- } else {
- Issue.record("expected command content")
- }
-
- let commandCompleted = try decodeNotification("""
- {
- "method": "item/completed",
- "params": {
- "completedAtMs": 2000,
- "item": {
- "id": "cmd-1",
- "type": "commandExecution",
- "command": "swift test",
- "aggregatedOutput": "ok",
- "exitCode": 0,
- "durationMs": 1000
- }
- }
- }
- """)
-
- guard case .itemCompleted(let completedSeed) = try #require(commandCompleted.domainEvents().first) else {
- Issue.record("expected command item completion")
- return
- }
- #expect(completedSeed.phase == .completed)
- #expect(completedSeed.completedAt == Date(timeIntervalSince1970: 2))
- #expect(completedSeed.durationMs == 1000)
- if case .command(let command) = completedSeed.content {
- #expect(command.command == "swift test")
- #expect(command.output == "ok")
- #expect(command.exitCode == 0)
- } else {
- Issue.record("expected completed command content")
- }
-
- let commandFailed = try decodeNotification("""
- {
- "method": "item/completed",
- "params": {
- "item": {
- "id": "cmd-2",
- "type": "commandExecution",
- "command": "swift test",
- "aggregatedOutput": "",
- "exitCode": 1
- }
- }
- }
- """)
-
- guard case .itemCompleted(let failedSeed) = try #require(commandFailed.domainEvents().first) else {
- Issue.record("expected failed command item completion")
- return
- }
- #expect(failedSeed.phase == .failed)
-
- let searchStarted = try decodeNotification("""
- {
- "method": "item/started",
- "params": {
- "item": {
- "id": "search-1",
- "type": "webSearch",
- "query": "Swift Testing"
- }
- }
- }
- """)
-
- guard case .itemStarted(let searchSeed) = try #require(searchStarted.domainEvents().first) else {
- Issue.record("expected search item start")
- return
- }
- #expect(searchSeed.family == .search)
- if case .search(let search) = searchSeed.content {
- #expect(search.query == "Swift Testing")
- #expect(search.query != "Search")
- } else {
- Issue.record("expected search content")
- }
-
- let toolCompleted = try decodeNotification("""
- {
- "method": "item/completed",
- "params": {
- "item": {
- "id": "tool-1",
- "type": "mcpToolCall",
- "server": "codex_review",
- "tool": "review_read",
- "result": {
- "ok": true
- }
- }
- }
- }
- """)
-
- guard case .itemCompleted(let toolSeed) = try #require(toolCompleted.domainEvents().first) else {
- Issue.record("expected tool item completion")
- return
- }
- #expect(toolSeed.family == .tool)
- if case .toolCall(let tool) = toolSeed.content {
- #expect(tool.server == "codex_review")
- #expect(tool.tool == "review_read")
- #expect(tool.result == "{\"ok\":true}")
- } else {
- Issue.record("expected tool content")
- }
-
- let approvalCompleted = try decodeNotification("""
- {
- "method": "item/completed",
- "params": {
- "item": {
- "id": "approval-1",
- "type": "hookPrompt",
- "prompt": "Allow command?"
- }
- }
- }
- """)
-
- guard case .itemCompleted(let approvalSeed) = try #require(approvalCompleted.domainEvents().first) else {
- Issue.record("expected approval item completion")
- return
- }
- #expect(approvalSeed.family == .approval)
- if case .approval(let approval) = approvalSeed.content {
- #expect(approval.title == "Allow command?")
- #expect(approval.title != "Hook prompt completed.")
- } else {
- Issue.record("expected approval content")
- }
-
- let fragmentedApproval = try decodeNotification("""
- {
- "method": "item/completed",
- "params": {
- "item": {
- "id": "approval-2",
- "type": "hookPrompt",
- "fragments": [
- {
- "text": "Allow file write?"
- }
- ]
- }
- }
- }
- """)
-
- guard case .itemCompleted(let fragmentedApprovalSeed) = try #require(fragmentedApproval.domainEvents().first) else {
- Issue.record("expected fragmented approval completion")
- return
- }
- if case .approval(let approval) = fragmentedApprovalSeed.content {
- #expect(approval.title == "Allow file write?")
- } else {
- Issue.record("expected fragmented approval content")
- }
-
- let userMessage = try decodeNotification("""
- {
- "method": "item/completed",
- "params": {
- "item": {
- "id": "user-1",
- "type": "userMessage",
- "content": [
- {
- "text": "Please review this diff"
- }
- ]
- }
- }
- }
- """)
-
- guard case .itemCompleted(let userSeed) = try #require(userMessage.domainEvents().first) else {
- Issue.record("expected user message completion")
- return
- }
- if case .message(let message) = userSeed.content {
- #expect(message.text == "Please review this diff")
- } else {
- Issue.record("expected user message content")
- }
- }
-
- @Test func decodesCommandOutputAggregationEntryPointsWithoutDisplayText() throws {
- let encoded = Data("Build complete\n".utf8).base64EncodedString()
- for method in ["item/commandExecution/outputDelta", "command/exec/outputDelta", "process/outputDelta"] {
- let deltaField = method == "item/commandExecution/outputDelta"
- ? #""delta": "Build complete\n""#
- : #""deltaBase64": "\#(encoded)""#
- let notification = try decodeNotification("""
- {
- "method": "\(method)",
- "params": {
- "itemId": "cmd-1",
- \(deltaField)
- }
- }
- """)
-
- let events = notification.domainEvents()
- guard case .textDelta(let itemID, let kind, let family, let content, let delta) = try #require(events.first) else {
- Issue.record("expected text delta for \(method)")
- return
- }
- #expect(itemID.rawValue == "cmd-1")
- #expect(kind == .commandExecution)
- #expect(family == .command)
- #expect(delta == "Build complete\n")
- if case .command(let command) = content {
- #expect(command.command.isEmpty)
- #expect(command.output.isEmpty)
- } else {
- Issue.record("expected command content")
- }
- }
-
- let processOutput = try decodeNotification("""
- {
- "method": "process/outputDelta",
- "params": {
- "processHandle": "process-1",
- "deltaBase64": "\(encoded)"
- }
- }
- """)
-
- guard case .textDelta(let processItemID, _, _, _, _) = try #require(processOutput.domainEvents().first) else {
- Issue.record("expected process output delta")
- return
- }
- #expect(processItemID.rawValue == "process-1")
-
- let invalidUTF8Output = Data([0xff, 0x0a]).base64EncodedString()
- let lossyProcessOutput = try decodeNotification("""
- {
- "method": "process/outputDelta",
- "params": {
- "processHandle": "process-1",
- "deltaBase64": "\(invalidUTF8Output)"
- }
- }
- """)
-
- guard case .textDelta(_, _, _, _, let lossyDelta) = try #require(lossyProcessOutput.domainEvents().first) else {
- Issue.record("expected lossy process output delta")
- return
- }
- #expect(lossyDelta == String(decoding: [0xff, 0x0a], as: UTF8.self))
-
- let bareCompletion = try decodeNotification("""
- {
- "method": "item/completed",
- "params": {
- "item": {
- "id": "cmd-1",
- "type": "commandExecution"
- }
- }
- }
- """)
-
- #expect(bareCompletion.domainEvents().isEmpty)
-
- let explicitEmptyAggregatedOutput = try decodeNotification("""
- {
- "method": "item/completed",
- "params": {
- "item": {
- "id": "cmd-1",
- "type": "commandExecution",
- "aggregatedOutput": ""
- }
- }
- }
- """)
-
- guard case .itemCompleted(let explicitEmptySeed) = try #require(explicitEmptyAggregatedOutput.domainEvents().first) else {
- Issue.record("expected explicit empty aggregate to complete command")
- return
- }
- #expect(explicitEmptySeed.phase == .completed)
- if case .command(let command) = explicitEmptySeed.content {
- #expect(command.output.isEmpty)
- } else {
- Issue.record("expected command content")
- }
-
- let emptyAggregatedOutputCompletion = try decodeNotification("""
- {
- "method": "item/completed",
- "params": {
- "completedAtMs": 2000,
- "item": {
- "id": "cmd-1",
- "type": "commandExecution",
- "command": "swift test",
- "aggregatedOutput": "",
- "exitCode": 0,
- "durationMs": 1000
- }
- }
- }
- """)
-
- guard case .itemCompleted(let emptyOutputSeed) = try #require(emptyAggregatedOutputCompletion.domainEvents().first) else {
- Issue.record("expected empty-output command completion with lifecycle metadata")
- return
- }
- #expect(emptyOutputSeed.phase == .completed)
- #expect(emptyOutputSeed.completedAt == Date(timeIntervalSince1970: 2))
- #expect(emptyOutputSeed.durationMs == 1000)
- if case .command(let command) = emptyOutputSeed.content {
- #expect(command.command == "swift test")
- #expect(command.output.isEmpty)
- #expect(command.exitCode == 0)
- } else {
- Issue.record("expected command content")
- }
- }
-
- @Test func preservesFailedCommandCompletionWithEmptyAggregatedOutput() throws {
- let failed = try decodeNotification("""
- {
- "method": "item/completed",
- "params": {
- "item": {
- "id": "cmd-1",
- "type": "commandExecution",
- "command": "swift test",
- "aggregatedOutput": "",
- "exitCode": 1
- }
- }
- }
- """)
-
- guard case .itemCompleted(let seed) = try #require(failed.domainEvents().first) else {
- Issue.record("expected failed command completion")
- return
- }
- #expect(seed.phase == .failed)
- if case .command(let command) = seed.content {
- #expect(command.command == "swift test")
- #expect(command.output.isEmpty)
- #expect(command.exitCode == 1)
- } else {
- Issue.record("expected command content")
- }
- }
-
- @Test @MainActor func emptyCommandCompletionPreservesStreamedOutputAndCompletesItem() throws {
- let started = try decodeNotification("""
- {
- "method": "item/started",
- "params": {
- "item": {
- "id": "cmd-1",
- "type": "commandExecution",
- "command": "swift test"
- }
- }
- }
- """)
-
- let delta = try decodeNotification("""
- {
- "method": "item/commandExecution/outputDelta",
- "params": {
- "itemId": "cmd-1",
- "delta": "streamed output"
- }
- }
- """)
-
- let completed = try decodeNotification("""
- {
- "method": "item/completed",
- "params": {
- "item": {
- "id": "cmd-1",
- "type": "commandExecution",
- "command": "swift test",
- "aggregatedOutput": ""
- }
- }
- }
- """)
-
- let timeline = ReviewTimeline()
- for event in started.domainEvents() + delta.domainEvents() + completed.domainEvents() {
- timeline.apply(event)
- }
-
- let item = try #require(timeline.item(for: "cmd-1"))
- #expect(item.phase == .completed)
- if case .command(let command) = item.content {
- #expect(command.output == "streamed output")
- #expect(command.exitCode == nil)
- } else {
- Issue.record("expected command content")
- }
- }
-
- @Test @MainActor func successOnlyEmptyCommandCompletionPreservesStreamedOutputAndCompletesItem() throws {
- let started = try decodeNotification("""
- {
- "method": "item/started",
- "params": {
- "item": {
- "id": "cmd-1",
- "type": "commandExecution",
- "command": "swift test"
- }
- }
- }
- """)
-
- let delta = try decodeNotification("""
- {
- "method": "item/commandExecution/outputDelta",
- "params": {
- "itemId": "cmd-1",
- "delta": "streamed output"
- }
- }
- """)
-
- let completed = try decodeNotification("""
- {
- "method": "item/completed",
- "params": {
- "item": {
- "id": "cmd-1",
- "type": "commandExecution",
- "command": "swift test",
- "aggregatedOutput": "",
- "success": true
- }
- }
- }
- """)
-
- let timeline = ReviewTimeline()
- for event in started.domainEvents() + delta.domainEvents() + completed.domainEvents() {
- timeline.apply(event)
- }
-
- let item = try #require(timeline.item(for: "cmd-1"))
- #expect(item.phase == .completed)
- if case .command(let command) = item.content {
- #expect(command.output == "streamed output")
- #expect(command.exitCode == nil)
- } else {
- Issue.record("expected command content")
- }
- }
-
- @Test @MainActor func commandOnlyCompletionPreservesStreamedOutputAndCompletesItem() throws {
- let started = try decodeNotification("""
- {
- "method": "item/started",
- "params": {
- "item": {
- "id": "cmd-1",
- "type": "commandExecution",
- "command": "swift test"
- }
- }
- }
- """)
-
- let delta = try decodeNotification("""
- {
- "method": "item/commandExecution/outputDelta",
- "params": {
- "itemId": "cmd-1",
- "delta": "streamed output"
- }
- }
- """)
-
- let completed = try decodeNotification("""
- {
- "method": "item/completed",
- "params": {
- "item": {
- "id": "cmd-1",
- "type": "commandExecution",
- "command": "swift test"
- }
- }
- }
- """)
-
- let timeline = ReviewTimeline()
- for event in started.domainEvents() + delta.domainEvents() + completed.domainEvents() {
- timeline.apply(event)
- }
-
- let item = try #require(timeline.item(for: "cmd-1"))
- #expect(item.phase == .completed)
- if case .command(let command) = item.content {
- #expect(command.command == "swift test")
- #expect(command.output == "streamed output")
- #expect(command.exitCode == nil)
- } else {
- Issue.record("expected command content")
- }
- }
-
- @Test func preservesReasoningDeltaIndexesInItemIDs() throws {
- let summary = try decodeNotification("""
- {
- "method": "item/reasoning/summaryTextDelta",
- "params": {
- "itemId": "reasoning-1",
- "summaryIndex": 2,
- "delta": "summary"
- }
- }
- """)
-
- guard case .textDelta(let summaryItemID, _, _, _, _) = try #require(summary.domainEvents().first) else {
- Issue.record("expected summary reasoning delta")
- return
- }
- #expect(summaryItemID.rawValue == "reasoning-1:summary:2")
-
- let raw = try decodeNotification("""
- {
- "method": "item/reasoning/textDelta",
- "params": {
- "itemId": "reasoning-1",
- "contentIndex": 3,
- "delta": "raw"
- }
- }
- """)
-
- guard case .textDelta(let rawItemID, _, _, _, _) = try #require(raw.domainEvents().first) else {
- Issue.record("expected raw reasoning delta")
- return
- }
- #expect(rawItemID.rawValue == "reasoning-1:content:3")
- }
-
- @Test func completesReasoningPartsWithDeltaItemIDs() throws {
- let completed = try decodeNotification("""
- {
- "method": "item/completed",
- "params": {
- "completedAtMs": 3000,
- "item": {
- "id": "reasoning-1",
- "type": "reasoning",
- "summary": ["first final", "summary replacement"],
- "content": ["raw final", "other raw", "raw chain plus final"]
- }
- }
- }
- """)
-
- let events = completed.domainEvents()
- let seeds = events.compactMap { event -> ReviewTimelineItemSeed? in
- guard case .itemCompleted(let seed) = event else {
- return nil
- }
- return seed
- }
-
- #expect(seeds.count == 5)
- #expect(seeds.map(\.id.rawValue) == [
- "reasoning-1:summary:0",
- "reasoning-1:summary:1",
- "reasoning-1:content:0",
- "reasoning-1:content:1",
- "reasoning-1:content:2"
- ])
- #expect(seeds.contains { $0.id.rawValue == "reasoning-1" } == false)
- #expect(seeds.allSatisfy { $0.phase == .completed })
- #expect(seeds.allSatisfy { $0.completedAt == Date(timeIntervalSince1970: 3) })
-
- let reasoning = seeds.compactMap { seed -> ReviewTimelineItem.Reasoning? in
- guard case .reasoning(let content) = seed.content else {
- return nil
- }
- return content
- }
-
- #expect(reasoning.map(\.text) == [
- "first final",
- "summary replacement",
- "raw final",
- "other raw",
- "raw chain plus final"
- ])
- #expect(reasoning.map(\.style) == [.summary, .summary, .raw, .raw, .raw])
- }
-
- @Test func ignoresReasoningParentStartsAndUpdatesWithoutPartParentContentOrLifecycleMetadata() throws {
- for method in ["item/started", "item/updated"] {
- let notification = try decodeNotification("""
- {
- "method": "\(method)",
- "params": {
- "item": {
- "id": "reasoning-1",
- "type": "reasoning"
- }
- }
- }
- """)
-
- #expect(notification.domainEvents().isEmpty)
- }
- }
-
- @Test @MainActor func statusOnlyReasoningUpdateCompletesExistingParent() throws {
- let started = try decodeNotification("""
- {
- "method": "item/started",
- "params": {
- "item": {
- "id": "reasoning-1",
- "type": "reasoning",
- "text": "parent reasoning"
- }
- }
- }
- """)
-
- let failed = try decodeNotification("""
- {
- "method": "item/updated",
- "params": {
- "item": {
- "id": "reasoning-1",
- "type": "reasoning",
- "status": "failed"
- }
- }
- }
- """)
-
- guard case .itemStarted(let startSeed) = try #require(started.domainEvents().first),
- case .itemUpdated(let updateSeed) = try #require(failed.domainEvents().first)
- else {
- Issue.record("expected reasoning start and update")
- return
- }
- #expect(updateSeed.phase == .failed)
-
- let timeline = ReviewTimeline()
- timeline.apply(.itemStarted(startSeed))
- timeline.apply(.itemUpdated(updateSeed))
- let item = try #require(timeline.item(for: "reasoning-1"))
- #expect(item.phase == .failed)
- if case .reasoning(let reasoning) = item.content {
- #expect(reasoning.text == "parent reasoning")
- } else {
- Issue.record("expected reasoning content")
- }
- }
-
- @Test @MainActor func successAndErrorReasoningUpdatesCompleteExistingParent() throws {
- for (field, expectedPhase) in [
- (#""success": true"#, ReviewItemPhase.completed),
- (#""error": "boom""#, ReviewItemPhase.failed)
- ] {
- let started = try decodeNotification("""
- {
- "method": "item/started",
- "params": {
- "item": {
- "id": "reasoning-1",
- "type": "reasoning",
- "text": "parent reasoning"
- }
- }
- }
- """)
-
- let lifecycleUpdate = try decodeNotification("""
- {
- "method": "item/updated",
- "params": {
- "item": {
- "id": "reasoning-1",
- "type": "reasoning",
- \(field)
- }
- }
- }
- """)
-
- guard case .itemStarted(let startSeed) = try #require(started.domainEvents().first),
- case .itemUpdated(let updateSeed) = try #require(lifecycleUpdate.domainEvents().first)
- else {
- Issue.record("expected reasoning start and lifecycle update")
- return
- }
- #expect(updateSeed.phase == expectedPhase)
-
- let timeline = ReviewTimeline()
- timeline.apply(.itemStarted(startSeed))
- timeline.apply(.itemUpdated(updateSeed))
- let item = try #require(timeline.item(for: "reasoning-1"))
- #expect(item.phase == expectedPhase)
- if case .reasoning(let reasoning) = item.content {
- #expect(reasoning.text == "parent reasoning")
- } else {
- Issue.record("expected reasoning content")
- }
- }
- }
-
- @Test @MainActor func completesReasoningParentWithBareCompletion() throws {
- let parentText = try decodeNotification("""
- {
- "method": "item/started",
- "params": {
- "item": {
- "id": "reasoning-2",
- "type": "reasoning",
- "text": "parent reasoning"
- }
- }
- }
- """)
-
- guard case .itemStarted(let startSeed) = try #require(parentText.domainEvents().first) else {
- Issue.record("expected parent reasoning start")
- return
- }
- #expect(startSeed.id.rawValue == "reasoning-2")
- if case .reasoning(let reasoning) = startSeed.content {
- #expect(reasoning.text == "parent reasoning")
- } else {
- Issue.record("expected reasoning content")
- }
-
- let bareCompletion = try decodeNotification("""
- {
- "method": "item/completed",
- "params": {
- "item": {
- "id": "reasoning-2",
- "type": "reasoning"
- }
- }
- }
- """)
-
- guard case .itemCompleted(let completionSeed) = try #require(bareCompletion.domainEvents().first) else {
- Issue.record("expected bare reasoning completion")
- return
- }
- #expect(completionSeed.id.rawValue == "reasoning-2")
- #expect(completionSeed.phase == .completed)
- #expect(completionSeed.completedAt == nil)
-
- let timeline = ReviewTimeline()
- timeline.apply(.itemStarted(startSeed))
- timeline.apply(.itemCompleted(completionSeed))
- let item = try #require(timeline.item(for: "reasoning-2"))
- #expect(item.phase == .completed)
- if case .reasoning(let reasoning) = item.content {
- #expect(reasoning.text == "parent reasoning")
- } else {
- Issue.record("expected timeline reasoning content")
- }
- }
-
- @Test func doesNotDuplicatePlainStringMessageContentFragments() throws {
- let notification = try decodeNotification("""
- {
- "method": "item/completed",
- "params": {
- "item": {
- "id": "message-1",
- "type": "agentMessage",
- "content": [
- "Line one",
- "Line two"
- ]
- }
- }
- }
- """)
-
- guard case .itemCompleted(let seed) = try #require(notification.domainEvents().first) else {
- Issue.record("expected message completion")
- return
- }
- if case .message(let message) = seed.content {
- #expect(message.text == "Line one\nLine two")
- } else {
- Issue.record("expected message content")
- }
- }
-
- @Test func mapsPartialProgressUpdatesToScopedItems() throws {
- let toolProgress = try decodeNotification("""
- {
- "method": "item/mcpToolCall/progress",
- "params": {
- "itemId": "tool-1",
- "message": "Reading review job"
- }
- }
- """)
-
- guard case .itemUpdated(let toolSeed) = try #require(toolProgress.domainEvents().first) else {
- Issue.record("expected tool progress update")
- return
- }
- #expect(toolSeed.id.rawValue == "tool-1:progress")
- #expect(toolSeed.family == .tool)
- if case .toolCall(let tool) = toolSeed.content {
- #expect(tool.progress == "Reading review job")
- #expect(tool.result == nil)
- #expect(tool.server == nil)
- #expect(tool.tool == nil)
- } else {
- Issue.record("expected tool progress content")
- }
-
- let filePatch = try decodeNotification("""
- {
- "method": "item/fileChange/patchUpdated",
- "params": {
- "itemId": "file-1",
- "changes": [
- {
- "path": "Sources/App.swift",
- "kind": "modify",
- "diff": "diff --git"
- }
- ]
- }
- }
- """)
-
- guard case .itemUpdated(let fileSeed) = try #require(filePatch.domainEvents().first) else {
- Issue.record("expected file patch update")
- return
- }
- #expect(fileSeed.id.rawValue == "file-1:patch")
- #expect(fileSeed.family == .fileChange)
- if case .fileChange(let fileChange) = fileSeed.content {
- #expect(fileChange.title == "Sources/App.swift")
- #expect(fileChange.output == "modify\nSources/App.swift\ndiff --git")
- } else {
- Issue.record("expected file patch content")
- }
- }
-
- @Test func mapsTerminalAndDiagnosticNotificationsWithoutFallbackDisplayText() throws {
- let completed = try decodeNotification("""
- {
- "method": "turn/completed",
- "params": {
- "turn": {
- "id": "turn-1",
- "status": "completed"
- }
- }
- }
- """)
-
- guard case .reviewCompleted(let summary, let result) = try #require(completed.domainEvents().first) else {
- Issue.record("expected review completion")
- return
- }
- #expect(summary.isEmpty)
- #expect(result == nil)
-
- let failed = try decodeNotification("""
- {
- "method": "turn/failed",
- "params": {
- "turnId": "turn-2"
- }
- }
- """)
-
- guard case .reviewFailed(let failureMessage) = try #require(failed.domainEvents().first) else {
- Issue.record("expected review failure")
- return
- }
- #expect(failureMessage.isEmpty)
-
- let aborted = try decodeNotification("""
- {
- "method": "turn/aborted",
- "params": {
- "turnId": "turn-3"
- }
- }
- """)
-
- guard case .reviewCancelled(let cancellationMessage) = try #require(aborted.domainEvents().first) else {
- Issue.record("expected review cancellation")
- return
- }
- #expect(cancellationMessage.isEmpty)
-
- let retrying = try decodeNotification("""
- {
- "method": "error",
- "params": {
- "turnId": "turn-4",
- "message": "Retrying request",
- "willRetry": true
- }
- }
- """)
-
- #expect(retrying.payload.rawFields["willRetry"] == .bool(true))
- guard case .itemUpdated(let retrySeed) = try #require(retrying.domainEvents().first) else {
- Issue.record("expected retry diagnostic update")
- return
- }
- #expect(retrySeed.kind.rawValue == "error")
- #expect(retrySeed.family == .diagnostic)
- #expect(retrySeed.phase == .running)
- if case .diagnostic(let diagnostic) = retrySeed.content {
- #expect(diagnostic.message == "Retrying request")
- } else {
- Issue.record("expected diagnostic content")
- }
-
- let deprecation = try decodeNotification("""
- {
- "method": "deprecationNotice",
- "params": {
- "turnId": "turn-5",
- "summary": "Deprecated setting",
- "details": "Use the replacement setting."
- }
- }
- """)
-
- guard case .itemUpdated(let deprecationSeed) = try #require(deprecation.domainEvents().first) else {
- Issue.record("expected deprecation diagnostic update")
- return
- }
- if case .diagnostic(let diagnostic) = deprecationSeed.content {
- #expect(diagnostic.message == "Deprecated setting\nUse the replacement setting.")
- } else {
- Issue.record("expected deprecation diagnostic content")
- }
-
- let warning = try decodeNotification("""
- {
- "method": "warning",
- "params": {
- "turnId": "turn-5",
- "message": "Model warning"
- }
- }
- """)
-
- guard case .itemUpdated(let warningSeed) = try #require(warning.domainEvents().first) else {
- Issue.record("expected warning diagnostic update")
- return
- }
- #expect(warningSeed.id.rawValue == "turn-5:warning")
- #expect(deprecationSeed.id.rawValue == "turn-5:deprecationNotice")
- #expect(warningSeed.id != deprecationSeed.id)
-
- let rerouted = try decodeNotification("""
- {
- "method": "model/rerouted",
- "params": {
- "turnId": "turn-6",
- "fromModel": "gpt-5",
- "toModel": "gpt-5.1",
- "reason": "policy"
- }
- }
- """)
-
- guard case .itemUpdated(let reroutedSeed) = try #require(rerouted.domainEvents().first) else {
- Issue.record("expected model reroute diagnostic")
- return
- }
- if case .diagnostic(let diagnostic) = reroutedSeed.content {
- #expect(diagnostic.message == "gpt-5 -> gpt-5.1\npolicy")
- } else {
- Issue.record("expected model reroute diagnostic content")
- }
-
- let verification = try decodeNotification("""
- {
- "method": "model/verification",
- "params": {
- "turnId": "turn-7",
- "verifications": ["capability", "safety"]
- }
- }
- """)
-
- guard case .itemUpdated(let verificationSeed) = try #require(verification.domainEvents().first) else {
- Issue.record("expected model verification diagnostic")
- return
- }
- if case .diagnostic(let diagnostic) = verificationSeed.content {
- #expect(diagnostic.message == "capability\nsafety")
- } else {
- Issue.record("expected model verification diagnostic content")
- }
- }
-
- @Test func mapsTurnPlanUpdatesToPlanContent() throws {
- let notification = try decodeNotification("""
- {
- "method": "turn/plan/updated",
- "params": {
- "turnId": "turn-1",
- "plan": [
- {
- "step": "Inspect diff",
- "status": "inProgress"
- },
- {
- "step": "Write findings",
- "status": "pending"
- }
- ]
- }
- }
- """)
-
- let events = notification.domainEvents()
- guard case .itemUpdated(let seed) = try #require(events.first) else {
- Issue.record("expected plan item update")
- return
- }
- #expect(seed.id.rawValue == "turn-1:turn/plan/updated")
- #expect(seed.kind == .plan)
- #expect(seed.family == .plan)
- if case .plan(let plan) = seed.content {
- #expect(plan.markdown == "[inProgress] Inspect diff\n[pending] Write findings")
- } else {
- Issue.record("expected plan content")
- }
-
- let diff = try decodeNotification("""
- {
- "method": "turn/diff/updated",
- "params": {
- "turnId": "turn-1",
- "diff": "diff --git"
- }
- }
- """)
-
- guard case .itemUpdated(let diffSeed) = try #require(diff.domainEvents().first) else {
- Issue.record("expected diff item update")
- return
- }
- #expect(diffSeed.id.rawValue == "turn-1:turn/diff/updated")
- #expect(diffSeed.kind.rawValue == "turn/diff/updated")
- #expect(diffSeed.id != seed.id)
-
- let patch = try decodeNotification("""
- {
- "method": "item/fileChange/patchUpdated",
- "params": {
- "turnId": "turn-1",
- "itemId": "file-1",
- "diff": "diff --git"
- }
- }
- """)
-
- guard case .itemUpdated(let patchSeed) = try #require(patch.domainEvents().first) else {
- Issue.record("expected file patch item update")
- return
- }
- #expect(patchSeed.id.rawValue == "file-1:patch")
- #expect(patchSeed.kind.rawValue == "item/fileChange/patchUpdated")
- }
-
- @Test func mapsTerminalThreadNotificationsBeforeUnknownFallback() throws {
- let closed = try decodeNotification("""
- {
- "method": "thread/closed",
- "params": {
- "threadId": "thread-1"
- }
- }
- """)
-
- guard case .reviewFailed(let closedMessage) = try #require(closed.domainEvents().first) else {
- Issue.record("expected thread closed failure")
- return
- }
- #expect(closedMessage.isEmpty)
-
- let notLoaded = try decodeNotification("""
- {
- "method": "thread/status/changed",
- "params": {
- "threadId": "thread-1",
- "status": {
- "type": "notLoaded"
- }
- }
- }
- """)
-
- guard case .reviewFailed(let notLoadedMessage) = try #require(notLoaded.domainEvents().first) else {
- Issue.record("expected notLoaded failure")
- return
- }
- #expect(notLoadedMessage == "notLoaded")
-
- let interrupted = try decodeNotification("""
- {
- "method": "thread/status/changed",
- "params": {
- "threadId": "thread-1",
- "status": {
- "type": "interrupted"
- }
- }
- }
- """)
-
- guard case .reviewCancelled(let interruptedMessage) = try #require(interrupted.domainEvents().first) else {
- Issue.record("expected interrupted cancellation")
- return
- }
- #expect(interruptedMessage == "interrupted")
-
- let systemError = try decodeNotification("""
- {
- "method": "thread/status/changed",
- "params": {
- "threadId": "thread-1",
- "status": {
- "type": "systemError"
- }
- }
- }
- """)
-
- guard case .itemUpdated(let systemErrorSeed) = try #require(systemError.domainEvents().first) else {
- Issue.record("expected systemError diagnostic update")
- return
- }
- #expect(systemErrorSeed.family == .diagnostic)
- #expect(systemErrorSeed.phase == .running)
- if case .diagnostic(let diagnostic) = systemErrorSeed.content {
- #expect(diagnostic.message == "systemError")
- } else {
- Issue.record("expected systemError diagnostic content")
- }
- }
-
- @Test func ignoresReasoningSummaryBoundaryNotifications() throws {
- let notification = try decodeNotification("""
- {
- "method": "item/reasoning/summaryPartAdded",
- "params": {
- "itemId": "reasoning-1",
- "summaryIndex": 1
- }
- }
- """)
-
- #expect(notification.domainEvents().isEmpty)
- #expect(notification.payload.rawFields["summaryIndex"] == .int(1))
- }
-
- @Test func ignoresAutoApprovalReviewBoundaryNotifications() throws {
- for method in ["item/autoApprovalReview/started", "item/autoApprovalReview/completed"] {
- let notification = try decodeNotification("""
- {
- "method": "\(method)",
- "params": {
- "itemId": "approval-review-1"
- }
- }
- """)
-
- #expect(notification.domainEvents().isEmpty)
- #expect(notification.payload.rawFields["itemId"] == .string("approval-review-1"))
- }
- }
-}
-
-private func decodeNotification(_ json: String) throws -> AppServerWireReviewNotification {
- try JSONDecoder().decode(AppServerWireReviewNotification.self, from: Data(json.utf8))
-}
diff --git a/Tests/CodexReviewApplicationTests/ReviewObservationAwaiterTests.swift b/Tests/CodexReviewApplicationTests/ReviewObservationAwaiterTests.swift
deleted file mode 100644
index 944e1424..00000000
--- a/Tests/CodexReviewApplicationTests/ReviewObservationAwaiterTests.swift
+++ /dev/null
@@ -1,74 +0,0 @@
-import Foundation
-import Testing
-@testable import CodexReviewApplication
-import CodexReviewDomain
-
-@MainActor
-@Suite("review observation awaiter")
-struct ReviewObservationAwaiterTests {
- @Test func resumesWhenTimelineReachesTerminalState() async throws {
- let timeline = ReviewTimeline()
- timeline.apply(.itemStarted(.init(
- id: "cmd-1",
- kind: .commandExecution,
- family: .command,
- phase: .running,
- content: .command(.init(command: "swift test"))
- )))
-
- let task = Task { @MainActor in
- await ReviewObservationAwaiter.waitUntilTerminal(
- timeline: timeline,
- timeout: .seconds(1)
- )
- }
- await Task.yield()
-
- timeline.apply(.reviewCompleted(summary: "Done", result: nil))
-
- let result = await task.value
- #expect(result)
- }
-
- @Test func resumesWhenTimelineCancellationReachesTerminalState() async throws {
- let timeline = ReviewTimeline()
- timeline.apply(.itemStarted(.init(
- id: "cmd-1",
- kind: .commandExecution,
- family: .command,
- phase: .running,
- content: .command(.init(command: "swift test"))
- )))
-
- let task = Task { @MainActor in
- await ReviewObservationAwaiter.waitUntilTerminal(
- timeline: timeline,
- timeout: .seconds(1)
- )
- }
- await Task.yield()
-
- timeline.apply(.reviewCancelled("Stop"))
-
- let result = await task.value
- #expect(result)
- }
-
- @Test func returnsFalseOnTimeout() async throws {
- let timeline = ReviewTimeline()
- timeline.apply(.itemStarted(.init(
- id: "cmd-1",
- kind: .commandExecution,
- family: .command,
- phase: .running,
- content: .command(.init(command: "swift test"))
- )))
-
- let result = await ReviewObservationAwaiter.waitUntilTerminal(
- timeline: timeline,
- timeout: .milliseconds(10)
- )
-
- #expect(result == false)
- }
-}
diff --git a/Tests/CodexReviewDomainTests/ReviewTimelineTests.swift b/Tests/CodexReviewDomainTests/ReviewTimelineTests.swift
deleted file mode 100644
index 49b8f08d..00000000
--- a/Tests/CodexReviewDomainTests/ReviewTimelineTests.swift
+++ /dev/null
@@ -1,386 +0,0 @@
-import Foundation
-import Testing
-@testable import CodexReviewDomain
-
-@MainActor
-@Suite("review timeline")
-struct ReviewTimelineTests {
- @Test func typedIDsPreserveRawValue() throws {
- let id: ReviewTimelineItem.ID = "item-1"
- #expect(id.rawValue == "item-1")
- #expect(ReviewItemKind(rawValue: "futureItem").rawValue == "futureItem")
- #expect(ReviewWireEventKind(rawValue: "future/event").rawValue == "future/event")
-
- let encodedID = try JSONEncoder().encode(id)
- #expect(String(data: encodedID, encoding: .utf8) == #""item-1""#)
- #expect(try JSONDecoder().decode(ReviewTimelineItem.ID.self, from: encodedID) == id)
-
- let actionKind = ReviewCommandActionKind(rawValue: "futureAction")
- let encodedActionKind = try JSONEncoder().encode(actionKind)
- #expect(try JSONDecoder().decode(ReviewCommandActionKind.self, from: encodedActionKind) == actionKind)
- }
-
- @Test func contentCodingRetainsUnknownRawValues() throws {
- let content = ReviewTimelineItem.Content.unknown(.init(
- title: "Future item",
- detail: "raw detail",
- rawKind: .init(rawValue: "futureAppServerItem"),
- rawStatus: "needsExternalDecision",
- references: [
- .init(kind: .init(rawValue: "wirePayload"), value: "payload-1", label: "Wire payload"),
- ]
- ))
-
- let encoded = try JSONEncoder().encode(content)
- let decoded = try JSONDecoder().decode(ReviewTimelineItem.Content.self, from: encoded)
-
- #expect(decoded == content)
- }
-
- @Test func semanticContentCarriesLifecycleFields() throws {
- let content = ReviewTimelineItem.Content.toolCall(.init(
- namespace: "mcp",
- server: "codex_review",
- tool: "review_start",
- arguments: #"{"target":"baseBranch"}"#,
- result: "No findings.",
- error: nil,
- status: .completed,
- durationMs: 1_250,
- appContext: .init(rawValue: "reviewMonitor"),
- pluginID: .init(rawValue: "codex-review"),
- callID: "tool-call-1",
- progress: "awaiting backend"
- ))
-
- let encoded = try JSONEncoder().encode(content)
- let decoded = try JSONDecoder().decode(ReviewTimelineItem.Content.self, from: encoded)
-
- #expect(decoded == content)
-
- let command = ReviewTimelineItem.Command(
- command: "rg ReviewTimeline",
- cwd: "/repo",
- status: .inProgress,
- source: .init(rawValue: "appServer"),
- processID: "pid-1",
- actions: [
- .init(kind: .search, command: "rg ReviewTimeline", query: "ReviewTimeline"),
- ],
- durationMs: 42
- )
- #expect(command.actions.first?.kind == .search)
-
- let fileChange = ReviewTimelineItem.FileChange(
- title: "Sources/App.swift",
- paths: ["Sources/App.swift"],
- patch: "@@ patch",
- status: .updated
- )
- #expect(fileChange.paths == ["Sources/App.swift"])
- #expect(fileChange.status == .updated)
-
- let approval = ReviewTimelineItem.Approval(
- title: "Run command?",
- decision: .approved,
- scope: .init(rawValue: "command"),
- risk: .medium,
- status: .decided
- )
- #expect(approval.decision == .approved)
-
- let diagnostic = ReviewTimelineItem.Diagnostic(
- message: "Backend overloaded",
- severity: .warning,
- retry: .init(state: .scheduled, attempt: 1, maxAttempts: 3, delayMs: 500)
- )
- #expect(diagnostic.retry?.state == .scheduled)
- }
-
- @Test func commandOutputAggregatesIntoSameItemInstance() throws {
- let timeline = ReviewTimeline()
- let itemID: ReviewTimelineItem.ID = "cmd-1"
-
- timeline.apply(.itemStarted(.init(
- id: itemID,
- kind: .commandExecution,
- family: .command,
- phase: .running,
- content: .command(.init(command: "swift test", status: .inProgress))
- )))
- let item = try #require(timeline.item(for: itemID))
- let identity = ObjectIdentifier(item)
-
- timeline.apply(.textDelta(
- itemID: itemID,
- kind: .commandExecution,
- family: .command,
- content: .command(.init(command: "swift test")),
- delta: "first\n"
- ))
- timeline.apply(.textDelta(
- itemID: itemID,
- kind: .commandExecution,
- family: .command,
- content: .command(.init(command: "swift test")),
- delta: "second\n"
- ))
- timeline.apply(.itemCompleted(.init(
- id: itemID,
- kind: .commandExecution,
- family: .command,
- phase: .completed,
- content: .command(.init(
- command: "swift test",
- exitCode: 0,
- status: .completed,
- durationMs: 1_500
- )),
- durationMs: 1_500
- )))
-
- let updated = try #require(timeline.item(for: itemID))
- #expect(ObjectIdentifier(updated) == identity)
- #expect(updated.phase == .completed)
- #expect(updated.durationMs == 1_500)
- #expect(timeline.activeItemIDs.isEmpty)
- if case .command(let command) = updated.content {
- #expect(command.output == "first\nsecond\n")
- #expect(command.exitCode == 0)
- #expect(command.status == .completed)
- #expect(command.durationMs == 1_500)
- } else {
- Issue.record("expected command content")
- }
- }
-
- @Test func deltaBeforeSnapshotMergesIntoStartedItem() throws {
- let timeline = ReviewTimeline()
- let itemID: ReviewTimelineItem.ID = "cmd-placeholder"
-
- timeline.apply(.textDelta(
- itemID: itemID,
- kind: .commandExecution,
- family: .command,
- content: .command(.init(command: "Command")),
- delta: "early output\n"
- ))
- let placeholder = try #require(timeline.item(for: itemID))
- let identity = ObjectIdentifier(placeholder)
- #expect(timeline.activeItemIDs == [itemID])
-
- timeline.apply(.itemStarted(.init(
- id: itemID,
- kind: .commandExecution,
- family: .command,
- phase: .running,
- content: .command(.init(
- command: "git diff",
- cwd: "/repo",
- status: .inProgress,
- actions: [.init(kind: .read, path: "Sources/App.swift")]
- ))
- )))
-
- let updated = try #require(timeline.item(for: itemID))
- #expect(ObjectIdentifier(updated) == identity)
- if case .command(let command) = updated.content {
- #expect(command.command == "git diff")
- #expect(command.cwd == "/repo")
- #expect(command.output == "early output\n")
- #expect(command.status == .inProgress)
- #expect(command.actions == [.init(kind: .read, path: "Sources/App.swift")])
- } else {
- Issue.record("expected command content")
- }
- }
-
- @Test func deltaOnlyRunningItemIsActiveUntilTerminalEvent() throws {
- let timeline = ReviewTimeline()
- let itemID: ReviewTimelineItem.ID = "message-1"
-
- timeline.apply(.textDelta(
- itemID: itemID,
- kind: .agentMessage,
- family: .message,
- content: .message(.init(text: "")),
- delta: "partial"
- ))
-
- let item = try #require(timeline.item(for: itemID))
- #expect(item.phase == .running)
- #expect(timeline.activeItemIDs == [itemID])
- #expect(timeline.latestActivity == itemID)
-
- timeline.apply(.reviewCompleted(summary: "done", result: "partial"))
-
- #expect(timeline.activeItemIDs.isEmpty)
- }
-
- @Test func terminalStartedItemsDoNotBecomeActive() throws {
- let timeline = ReviewTimeline()
- let terminalPhases: [ReviewItemPhase] = [
- .cancelled,
- .completed,
- .failed,
- .incomplete,
- .skipped,
- ]
-
- for phase in terminalPhases {
- let itemID = ReviewTimelineItem.ID(rawValue: "message-\(phase.rawValue)")
- timeline.apply(.itemStarted(.init(
- id: itemID,
- kind: .agentMessage,
- family: .message,
- phase: phase,
- content: .message(.init(text: phase.rawValue))
- )))
-
- let item = try #require(timeline.item(for: itemID))
- #expect(item.phase == phase)
- #expect(timeline.activeItemIDs.contains(itemID) == false)
- }
-
- #expect(timeline.activeItemIDs.isEmpty)
- }
-
- @Test func terminalTimelineDoesNotReactivateLateItemEvents() throws {
- let timeline = ReviewTimeline()
- let itemID: ReviewTimelineItem.ID = "late-message"
-
- timeline.apply(.reviewCompleted(summary: "done", result: nil))
- timeline.apply(.itemStarted(.init(
- id: itemID,
- kind: .agentMessage,
- family: .message,
- phase: .running,
- content: .message(.init(text: "late"))
- )))
- timeline.apply(.textDelta(
- itemID: itemID,
- kind: .agentMessage,
- family: .message,
- content: .message(.init(text: "")),
- delta: " delta"
- ))
-
- let lateItem = try #require(timeline.item(for: itemID))
- #expect(lateItem.phase == .running)
- #expect(timeline.isTerminal)
- #expect(timeline.activeItemIDs.isEmpty)
-
- timeline.apply(.runStarted(turnID: "turn-2", reviewThreadID: "thread-1", model: "gpt"))
- timeline.apply(.itemUpdated(.init(
- id: itemID,
- kind: .agentMessage,
- family: .message,
- phase: .running,
- content: .message(.init(text: "new run"))
- )))
-
- #expect(timeline.isTerminal == false)
- #expect(timeline.activeItemIDs == [itemID])
- }
-
- @Test func itemStatusTransitionsDoNotEraseSemanticSnapshot() throws {
- let timeline = ReviewTimeline()
- let itemID: ReviewTimelineItem.ID = "tool-2"
-
- timeline.apply(.itemStarted(.init(
- id: itemID,
- kind: .mcpToolCall,
- family: .tool,
- phase: .running,
- content: .toolCall(.init(
- namespace: "mcp",
- server: "codex_review",
- tool: "review_start",
- status: .started,
- progress: "queued"
- ))
- )))
-
- timeline.apply(.itemUpdated(.init(
- id: itemID,
- kind: .mcpToolCall,
- family: .tool,
- phase: .running,
- content: .toolCall(.init(
- result: "partial",
- status: .inProgress,
- durationMs: 100
- ))
- )))
-
- timeline.apply(.itemCompleted(.init(
- id: itemID,
- kind: .mcpToolCall,
- family: .tool,
- phase: .failed,
- content: .toolCall(.init(
- error: "tool failed",
- status: .failed,
- durationMs: 200
- )),
- durationMs: 200
- )))
-
- let item = try #require(timeline.item(for: itemID))
- #expect(item.phase == .failed)
- #expect(item.durationMs == 200)
- #expect(timeline.activeItemIDs.isEmpty)
- if case .toolCall(let toolCall) = item.content {
- #expect(toolCall.namespace == "mcp")
- #expect(toolCall.server == "codex_review")
- #expect(toolCall.tool == "review_start")
- #expect(toolCall.result == "partial")
- #expect(toolCall.error == "tool failed")
- #expect(toolCall.status == .failed)
- #expect(toolCall.durationMs == 200)
- #expect(toolCall.progress == "queued")
- } else {
- Issue.record("expected tool call content")
- }
- }
-
- @Test func terminalEventClearsActiveItems() throws {
- let timeline = ReviewTimeline()
- timeline.apply(.itemStarted(.init(
- id: "tool-1",
- kind: .mcpToolCall,
- family: .tool,
- phase: .running,
- content: .toolCall(.init(server: "server", tool: "tool"))
- )))
- #expect(timeline.activeItemIDs == ["tool-1"])
-
- timeline.apply(.reviewFailed("failed"))
-
- #expect(timeline.activeItemIDs.isEmpty)
- #expect(timeline.terminalStatus == .failed)
- #expect(timeline.terminalSummary == "failed")
- }
-
- @Test func terminalStatusIsSeparateFromSummaryAndResult() throws {
- let timeline = ReviewTimeline()
-
- timeline.apply(.reviewCompleted(summary: "clean", result: "No findings"))
- #expect(timeline.isTerminal)
- #expect(timeline.terminalStatus == .succeeded)
- #expect(timeline.terminalSummary == "clean")
- #expect(timeline.terminalResult == "No findings")
-
- timeline.apply(.runStarted(turnID: "turn-2", reviewThreadID: "review-thread", model: "gpt"))
- #expect(timeline.isTerminal == false)
- #expect(timeline.terminalStatus == nil)
- #expect(timeline.terminalSummary == nil)
- #expect(timeline.terminalResult == nil)
-
- timeline.apply(.reviewCancelled("cancelled by user"))
- #expect(timeline.isTerminal)
- #expect(timeline.terminalStatus == .cancelled)
- #expect(timeline.terminalSummary == "cancelled by user")
- #expect(timeline.terminalResult == nil)
- }
-}
diff --git a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift
index ffed52d7..be7639c5 100644
--- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift
+++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift
@@ -2,12 +2,97 @@ import Foundation
import AppKit
import AuthenticationServices
import Testing
-import CodexReview
+import CodexAppServerKit
+import CodexAppServerKitTesting
+import CodexReviewKit
import CodexReviewAppServer
import CodexReviewHost
import CodexReviewMCPServer
import CodexReviewTesting
+private let testAuthenticationURL = URL(string: "https://example.com/auth")!
+
+private extension CodexReviewStore {
+ @MainActor
+ static func makeLiveStoreForTesting(
+ environment: [String: String],
+ runtimePreferences: CodexReviewRuntime.Preferences = .defaults,
+ nativeAuthenticationConfiguration: CodexReviewNativeAuthentication.Configuration? = nil,
+ webAuthenticationSessionFactory: @escaping CodexReviewNativeAuthentication.WebSessionFactory,
+ externalURLOpener: @escaping @MainActor @Sendable (URL) -> Void = { _ in },
+ mcpPortOwnerResolver: CodexReviewMCPPortOwnerResolver? = nil,
+ mcpHTTPServerBindChecker: CodexReviewMCPHTTPServerBindChecker? = nil,
+ shutdownCleanupTimeout: Duration = .seconds(2),
+ networkMonitor: any CodexReviewNetworkMonitoring = SystemCodexReviewNetworkMonitor(),
+ networkRecoveryPolicy: CodexReviewNetworkRecoveryPolicy = .default,
+ appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? = nil,
+ transport: FakeCodexAppServerTransport
+ ) -> CodexReviewStore {
+ makeLiveStoreForTesting(
+ environment: environment,
+ runtimePreferences: runtimePreferences,
+ nativeAuthenticationConfiguration: nativeAuthenticationConfiguration,
+ webAuthenticationSessionFactory: webAuthenticationSessionFactory,
+ externalURLOpener: externalURLOpener,
+ mcpPortOwnerResolver: mcpPortOwnerResolver,
+ mcpHTTPServerBindChecker: mcpHTTPServerBindChecker,
+ shutdownCleanupTimeout: shutdownCleanupTimeout,
+ networkMonitor: networkMonitor,
+ networkRecoveryPolicy: networkRecoveryPolicy,
+ appServerLifecycleHandler: appServerLifecycleHandler,
+ appServerFactory: { codexHomeURL in
+ try await CodexAppServerTestRuntime.start(
+ transport: transport,
+ codexHome: codexHomeURL.path
+ ).server
+ }
+ )
+ }
+
+ @MainActor
+ static func makeLiveStoreForTesting(
+ environment: [String: String],
+ runtimePreferences: CodexReviewRuntime.Preferences = .defaults,
+ nativeAuthenticationConfiguration: CodexReviewNativeAuthentication.Configuration? = nil,
+ webAuthenticationSessionFactory: @escaping CodexReviewNativeAuthentication.WebSessionFactory,
+ externalURLOpener: @escaping @MainActor @Sendable (URL) -> Void = { _ in },
+ mcpHTTPServerFactory: (@MainActor @Sendable (
+ CodexReviewStore,
+ CodexReviewMCPHTTPServer.Configuration,
+ ReviewMCPLogProjectionProvider?
+ ) -> any CodexReviewMCPHTTPServing)? = nil,
+ mcpPortOwnerResolver: CodexReviewMCPPortOwnerResolver? = nil,
+ mcpHTTPServerBindChecker: CodexReviewMCPHTTPServerBindChecker? = nil,
+ shutdownCleanupTimeout: Duration = .seconds(2),
+ networkMonitor: any CodexReviewNetworkMonitoring = SystemCodexReviewNetworkMonitor(),
+ networkRecoveryPolicy: CodexReviewNetworkRecoveryPolicy = .default,
+ appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? = nil,
+ transportFactory: @escaping @MainActor @Sendable (URL) async throws -> FakeCodexAppServerTransport
+ ) -> CodexReviewStore {
+ makeLiveStoreForTesting(
+ environment: environment,
+ runtimePreferences: runtimePreferences,
+ nativeAuthenticationConfiguration: nativeAuthenticationConfiguration,
+ webAuthenticationSessionFactory: webAuthenticationSessionFactory,
+ externalURLOpener: externalURLOpener,
+ mcpHTTPServerFactory: mcpHTTPServerFactory,
+ mcpPortOwnerResolver: mcpPortOwnerResolver,
+ mcpHTTPServerBindChecker: mcpHTTPServerBindChecker,
+ shutdownCleanupTimeout: shutdownCleanupTimeout,
+ networkMonitor: networkMonitor,
+ networkRecoveryPolicy: networkRecoveryPolicy,
+ appServerLifecycleHandler: appServerLifecycleHandler,
+ appServerFactory: { codexHomeURL in
+ let transport = try await transportFactory(codexHomeURL)
+ return try await CodexAppServerTestRuntime.start(
+ transport: transport,
+ codexHome: codexHomeURL.path
+ ).server
+ }
+ )
+ }
+}
+
@Suite("host composition")
@MainActor
struct CodexReviewHostTests {
@@ -50,7 +135,7 @@ struct CodexReviewHostTests {
}.first)
#expect(startReview.model == "gpt-5.5")
- await backend.yield(.completed(summary: "Succeeded.", result: nil))
+ await backend.yield(.completed(finalReview: "No issues found."))
await backend.finishEvents()
_ = try await reviewTask.value
}
@@ -169,7 +254,7 @@ struct CodexReviewHostTests {
@Test func liveStoreUsesRuntimePreferenceCodexHome() async throws {
let homeURL = try temporaryHome()
let configuredCodexHomeURL = homeURL.appendingPathComponent("custom-codex-home", isDirectory: true)
- let transport = FakeJSONRPCTransport()
+ let transport = FakeCodexAppServerTransport()
try await transport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
try await transport.enqueue(AppServerAPI.Account.Read.Response(), for: "account/read")
try await transport.enqueue(
@@ -193,9 +278,43 @@ struct CodexReviewHostTests {
await store.stop()
}
+ @Test func liveStorePublishesPrimaryAppServerLifecycle() async throws {
+ let homeURL = try temporaryHome()
+ let transport = FakeCodexAppServerTransport()
+ try await transport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
+ try await transport.enqueue(AppServerAPI.Account.Read.Response(), for: "account/read")
+ try await transport.enqueue(
+ AppServerAPI.Config.Read.Response(config: .init(model: "gpt-5")),
+ for: "config/read"
+ )
+ try await transport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list")
+ var observedLifecycleStates: [Bool] = []
+ let store = CodexReviewStore.makeLiveStoreForTesting(
+ environment: ["HOME": homeURL.path],
+ webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession,
+ mcpHTTPServerFactory: { _, configuration, _ in
+ NoopMCPHTTPServer(endpoint: configuration.url())
+ },
+ mcpHTTPServerBindChecker: { _ in },
+ appServerLifecycleHandler: { appServer in
+ observedLifecycleStates.append(appServer != nil)
+ },
+ transportFactory: { _ in transport }
+ )
+
+ await store.start(forceRestartIfNeeded: true)
+
+ #expect(store.serverState == .running)
+ #expect(observedLifecycleStates == [true])
+
+ await store.stop()
+
+ #expect(observedLifecycleStates == [true, false])
+ }
+
@Test func liveStorePassesRuntimePreferenceMCPPortAndPathToHTTPServerFactory() async throws {
let homeURL = try temporaryHome()
- let transport = FakeJSONRPCTransport()
+ let transport = FakeCodexAppServerTransport()
try await transport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
try await transport.enqueue(AppServerAPI.Account.Read.Response(), for: "account/read")
try await transport.enqueue(
@@ -204,6 +323,7 @@ struct CodexReviewHostTests {
)
try await transport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list")
var capturedConfiguration: CodexReviewMCPHTTPServer.Configuration?
+ var capturedLogProjectionProvider: ReviewMCPLogProjectionProvider?
let store = CodexReviewStore.makeLiveStoreForTesting(
environment: ["HOME": homeURL.path],
runtimePreferences: .init(
@@ -211,8 +331,9 @@ struct CodexReviewHostTests {
mcpPath: "custom-mcp"
),
webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession,
- mcpHTTPServerFactory: { store, configuration in
+ mcpHTTPServerFactory: { store, configuration, logProjectionProvider in
capturedConfiguration = configuration
+ capturedLogProjectionProvider = logProjectionProvider
return CodexReviewMCPHTTPServer(
adapter: CodexReviewMCPServer(store: store),
configuration: .init(
@@ -230,6 +351,7 @@ struct CodexReviewHostTests {
let serverURL = try #require(store.serverURL)
#expect(capturedConfiguration?.port == 54321)
+ #expect(capturedLogProjectionProvider != nil)
#expect(capturedConfiguration?.endpoint == "/custom-mcp")
#expect(serverURL.path == "/custom-mcp")
await store.stop()
@@ -244,7 +366,7 @@ struct CodexReviewHostTests {
environment: ["HOME": homeURL.path],
runtimePreferences: .init(mcpHost: "127.0.0.1", mcpPort: port),
webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession,
- mcpHTTPServerFactory: { _, configuration in
+ mcpHTTPServerFactory: { _, configuration, _ in
NoopMCPHTTPServer(endpoint: configuration.url())
},
mcpPortOwnerResolver: { configuration in
@@ -262,7 +384,7 @@ struct CodexReviewHostTests {
},
transportFactory: { _ in
didLaunchAppServer = true
- return FakeJSONRPCTransport()
+ return FakeCodexAppServerTransport()
}
)
@@ -287,7 +409,7 @@ struct CodexReviewHostTests {
environment: ["HOME": homeURL.path],
runtimePreferences: .init(mcpHost: "127.0.0.1", mcpPort: port),
webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession,
- mcpHTTPServerFactory: { _, configuration in
+ mcpHTTPServerFactory: { _, configuration, _ in
NoopMCPHTTPServer(endpoint: configuration.url())
},
mcpPortOwnerResolver: { _ in nil },
@@ -299,7 +421,7 @@ struct CodexReviewHostTests {
},
transportFactory: { _ in
didLaunchAppServer = true
- return FakeJSONRPCTransport()
+ return FakeCodexAppServerTransport()
}
)
@@ -337,7 +459,7 @@ struct CodexReviewHostTests {
let store = CodexReviewStore.makeLiveStoreForTesting(
environment: ["HOME": homeURL.path],
webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession,
- transport: FakeJSONRPCTransport()
+ transport: FakeCodexAppServerTransport()
)
let reviewAccount = try #require(store.auth.persistedAccounts.first {
@@ -353,55 +475,8 @@ struct CodexReviewHostTests {
#expect(providerAccount.capabilities.supportsRateLimitRefresh == false)
}
- @Test func liveStoreInfersMissingPersistedRegistryKind() throws {
- let homeURL = try temporaryHome()
- try writeRegistryRecords(
- homeURL: homeURL,
- activeAccountKey: nil,
- records: [
- [
- "accountKey": "review@example.com",
- "email": "review@example.com",
- "planType": "pro",
- ],
- [
- "accountKey": "api-key",
- "email": "API Key",
- "planType": "pro",
- ],
- [
- "accountKey": "amazon-bedrock",
- "email": "Amazon Bedrock",
- "planType": "pro",
- ],
- ]
- )
- let store = CodexReviewStore.makeLiveStoreForTesting(
- environment: ["HOME": homeURL.path],
- webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession,
- transport: FakeJSONRPCTransport()
- )
-
- let reviewAccount = try #require(store.auth.persistedAccounts.first {
- $0.accountKey == "review@example.com"
- })
- let apiKeyAccount = try #require(store.auth.persistedAccounts.first {
- $0.accountKey == "api-key"
- })
- let bedrockAccount = try #require(store.auth.persistedAccounts.first {
- $0.accountKey == "amazon-bedrock"
- })
-
- #expect(reviewAccount.kind == .chatGPT)
- #expect(reviewAccount.capabilities.supportsRateLimitRefresh)
- #expect(apiKeyAccount.kind == .apiKey)
- #expect(apiKeyAccount.capabilities.supportsRateLimitRefresh == false)
- #expect(bedrockAccount.kind == .amazonBedrock)
- #expect(bedrockAccount.capabilities.supportsRateLimitRefresh == false)
- }
-
@Test func liveStoreSkipsRateLimitRefreshForUnsupportedActiveAccount() async throws {
- let transport = FakeJSONRPCTransport()
+ let transport = FakeCodexAppServerTransport()
try await transport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
try await transport.enqueue(
TestAccountReadResponse(account: .init(type: "apiKey")),
@@ -432,8 +507,8 @@ struct CodexReviewHostTests {
])
}
- @Test func liveStoreCancelsLoginWhenAuthenticationSessionIsClosed() async throws {
- let transport = FakeJSONRPCTransport()
+ @Test func liveStoreCompletesBrowserLoginFromAccountNotifications() async throws {
+ let transport = FakeCodexAppServerTransport()
try await transport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
try await transport.enqueue(AppServerAPI.Account.Read.Response(), for: "account/read")
try await transport.enqueue(
@@ -449,8 +524,19 @@ struct CodexReviewHostTests {
),
for: "account/login/start"
)
- try await transport.enqueue(AppServerAPI.Account.Login.Cancel.Response(), for: "account/login/cancel")
+ try await transport.enqueue(
+ AppServerAPI.Account.Read.Response(account: .init(email: "new@example.com", planType: "plus")),
+ for: "account/read"
+ )
+ try await transport.enqueue(
+ AppServerAPI.Account.RateLimits.Response(rateLimits: .init(
+ limitID: "codex",
+ primary: .init(usedPercent: 20, windowDurationMins: 300)
+ )),
+ for: "account/rateLimits/read"
+ )
let sessions = FakeWebAuthenticationSessions()
+ let externalURLOpener = FakeExternalURLOpener()
let store = CodexReviewStore.makeLiveStoreForTesting(
environment: ["HOME": try temporaryHome().path],
nativeAuthenticationConfiguration: .init(
@@ -459,21 +545,34 @@ struct CodexReviewHostTests {
presentationAnchorProvider: { NSWindow() }
),
webAuthenticationSessionFactory: sessions.makeSession,
+ externalURLOpener: externalURLOpener.open,
transport: transport
)
await store.start(forceRestartIfNeeded: true)
+ await transport.waitForNotificationStreamCount(1)
await store.addAccount()
- let session = await sessions.waitForSession()
- await session.waitUntilWaitingForCallback()
+ await transport.waitForRequestCount(5)
#expect(store.auth.isAuthenticating)
-
- await session.closeFromAuthenticationWindow()
- await transport.waitForRequestCount(6)
- await waitUntil { store.auth.isAuthenticating == false }
-
- #expect(store.auth.isAuthenticating == false)
- #expect(store.auth.selectedAccount == nil)
+ #expect(sessions.createdSessionCount == 0)
+ #expect(externalURLOpener.openedURLs == [URL(string: "https://example.com/auth")!])
+ try await transport.emitServerNotification(
+ method: "account/login/completed",
+ params: TestLoginCompletedNotification(loginID: "login-1", success: true)
+ )
+ try await transport.emitServerNotification(
+ method: "account/updated",
+ params: EmptyResponse()
+ )
+ await waitUntil { store.auth.selectedAccount?.accountKey == "new@example.com" }
+ // The post-login rate-limit refresh lands asynchronously after the
+ // account update; wait for the full request sequence before asserting.
+ await transport.waitForRequestCount(7)
+ let loginRequest = try #require(await transport.recordedRequests().first {
+ $0.method == "account/login/start"
+ })
+ let loginParams = try JSONDecoder().decode(AppServerAPI.Account.Login.Params.self, from: loginRequest.params)
+ #expect(loginParams.nativeWebAuthentication == nil)
let methods = await transport.recordedRequests().map(\.method)
#expect(methods == [
"initialize",
@@ -481,12 +580,14 @@ struct CodexReviewHostTests {
"config/read",
"model/list",
"account/login/start",
- "account/login/cancel",
+ "account/read",
+ "account/rateLimits/read",
])
+ await store.stop()
}
- @Test func liveStoreCancelsLoginWhenAuthenticationSessionSetupFails() async throws {
- let transport = FakeJSONRPCTransport()
+ @Test func liveStoreUsesExternalBrowserWhenNativeSessionFactoryIsConfigured() async throws {
+ let transport = FakeCodexAppServerTransport()
try await transport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
try await transport.enqueue(AppServerAPI.Account.Read.Response(), for: "account/read")
try await transport.enqueue(
@@ -502,7 +603,12 @@ struct CodexReviewHostTests {
),
for: "account/login/start"
)
- try await transport.enqueue(AppServerAPI.Account.Login.Cancel.Response(), for: "account/login/cancel")
+ try await transport.enqueue(
+ AppServerAPI.Account.Login.Cancel.Response(),
+ for: "account/login/cancel"
+ )
+ var didCreateNativeSession = false
+ let externalURLOpener = FakeExternalURLOpener()
let store = CodexReviewStore.makeLiveStoreForTesting(
environment: ["HOME": try temporaryHome().path],
nativeAuthenticationConfiguration: .init(
@@ -511,30 +617,34 @@ struct CodexReviewHostTests {
presentationAnchorProvider: { NSWindow() }
),
webAuthenticationSessionFactory: { _, _, _, _ in
+ didCreateNativeSession = true
throw CodexReviewAPI.Error.io("Authentication presentation failed.")
},
+ externalURLOpener: externalURLOpener.open,
transport: transport
)
await store.start(forceRestartIfNeeded: true)
await store.addAccount()
- await transport.waitForRequestCount(6)
+ await transport.waitForRequestCount(5)
- #expect(failedMessage(from: store.auth.phase) == "Authentication presentation failed.")
- #expect(await transport.recordedRequests().map(\.method) == [
+ #expect(didCreateNativeSession == false)
+ #expect(store.auth.isAuthenticating)
+ #expect(externalURLOpener.openedURLs == [URL(string: "https://example.com/auth")!])
+ #expect(Array(await transport.recordedRequests().map(\.method).prefix(5)) == [
"initialize",
"account/read",
"config/read",
"model/list",
"account/login/start",
- "account/login/cancel",
])
+ await store.stop()
}
@Test func liveStoreAddsAccountWithoutSwitchingExistingActiveAccount() async throws {
let homeURL = try temporaryHome()
let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true)
- let mainTransport = FakeJSONRPCTransport()
+ let mainTransport = FakeCodexAppServerTransport()
try await mainTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
try await mainTransport.enqueue(
AppServerAPI.Account.Read.Response(
@@ -555,7 +665,7 @@ struct CodexReviewHostTests {
)
try await mainTransport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list")
- let authTransport = FakeJSONRPCTransport()
+ let authTransport = FakeCodexAppServerTransport()
try await authTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
try await authTransport.enqueue(
AppServerAPI.Account.Login.Response.chatgpt(
@@ -578,7 +688,7 @@ struct CodexReviewHostTests {
)),
for: "account/rateLimits/read"
)
- let refreshTransport = FakeJSONRPCTransport()
+ let refreshTransport = FakeCodexAppServerTransport()
let refreshGate = AsyncGate()
await refreshTransport.hold(method: "account/rateLimits/read", gate: refreshGate)
try await refreshTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
@@ -630,20 +740,15 @@ struct CodexReviewHostTests {
#expect(store.auth.selectedAccount?.accountKey == "active@example.com")
await store.addAccount()
- let session = await sessions.waitForSession()
- await session.waitUntilWaitingForCallback()
await authTransport.waitForNotificationStreamCount(1)
- #expect(sessions.createdSessionCount == 1)
- #expect(externalURLOpener.openedURLs == [])
+ await authTransport.waitForRequestCount(2)
+ #expect(sessions.createdSessionCount == 0)
+ #expect(externalURLOpener.openedURLs == [URL(string: "https://example.com/auth")!])
let loginRequest = try #require(await authTransport.recordedRequests().first {
$0.method == "account/login/start"
})
let loginParams = try JSONDecoder().decode(AppServerAPI.Account.Login.Params.self, from: loginRequest.params)
- #expect(loginParams.nativeWebAuthentication?.callbackURLScheme == "lynnpd.CodexReviewMonitor.auth")
- try await authTransport.emitServerNotification(
- method: "account/updated",
- params: EmptyResponse()
- )
+ #expect(loginParams.nativeWebAuthentication == nil)
try await authTransport.emitServerNotification(
method: "account/login/completed",
params: TestLoginCompletedNotification(loginID: "login-2", success: true)
@@ -652,7 +757,6 @@ struct CodexReviewHostTests {
method: "account/updated",
params: EmptyResponse()
)
- await authTransport.waitForRequestCount(4)
await waitUntil {
store.auth.persistedAccounts.contains { $0.accountKey == "new@example.com" }
&& store.auth.persistedAccounts.first { $0.accountKey == "new@example.com" }?.rateLimits.first?.usedPercent == 25
@@ -702,7 +806,7 @@ struct CodexReviewHostTests {
)
try writeSavedAccountAuth(homeURL: homeURL, accountKey: "new@example.com")
- let mainTransport = FakeJSONRPCTransport()
+ let mainTransport = FakeCodexAppServerTransport()
try await mainTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
try await mainTransport.enqueue(
AppServerAPI.Account.Read.Response(account: .init(email: "active@example.com", planType: "pro")),
@@ -721,7 +825,7 @@ struct CodexReviewHostTests {
)
try await mainTransport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list")
- let refreshTransport = FakeJSONRPCTransport()
+ let refreshTransport = FakeCodexAppServerTransport()
try await refreshTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
try await refreshTransport.enqueue(
AppServerAPI.Account.Read.Response(account: .init(email: "active@example.com", planType: "pro")),
@@ -767,7 +871,7 @@ struct CodexReviewHostTests {
activeAccountKey: nil,
accounts: ["existing@example.com"]
)
- let transport = FakeJSONRPCTransport()
+ let transport = FakeCodexAppServerTransport()
try await transport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
try await transport.enqueue(AppServerAPI.Account.Read.Response(), for: "account/read")
try await transport.enqueue(
@@ -783,7 +887,6 @@ struct CodexReviewHostTests {
),
for: "account/login/start"
)
- try await transport.enqueue(AppServerAPI.Account.Login.Complete.Response(), for: "account/login/complete")
try await transport.enqueue(
AppServerAPI.Account.Read.Response(account: .init(email: "new@example.com", planType: "plus")),
for: "account/read"
@@ -795,6 +898,7 @@ struct CodexReviewHostTests {
)),
for: "account/rateLimits/read"
)
+ let externalURLOpener = FakeExternalURLOpener()
let sessions = FakeWebAuthenticationSessions()
let store = CodexReviewStore.makeLiveStoreForTesting(
environment: ["HOME": homeURL.path],
@@ -804,6 +908,7 @@ struct CodexReviewHostTests {
presentationAnchorProvider: { NSWindow() }
),
webAuthenticationSessionFactory: sessions.makeSession,
+ externalURLOpener: externalURLOpener.open,
transportFactory: { codexHomeURL in
#expect(codexHomeURL == mainCodexHomeURL)
return transport
@@ -815,9 +920,17 @@ struct CodexReviewHostTests {
#expect(store.auth.persistedAccounts.map(\.accountKey) == ["existing@example.com"])
await store.addAccount()
- let session = await sessions.waitForSession()
- await session.waitUntilWaitingForCallback()
- session.complete(with: URL(string: "lynnpd.CodexReviewMonitor.auth://callback?code=1")!)
+ await transport.waitForRequestCount(5)
+ #expect(sessions.createdSessionCount == 0)
+ #expect(externalURLOpener.openedURLs == [URL(string: "https://example.com/auth")!])
+ try await transport.emitServerNotification(
+ method: "account/login/completed",
+ params: TestLoginCompletedNotification(loginID: "login-new", success: true)
+ )
+ try await transport.emitServerNotification(
+ method: "account/updated",
+ params: EmptyResponse()
+ )
await waitUntil {
store.auth.selectedAccount?.accountKey == "new@example.com"
&& store.auth.selectedAccount?.rateLimits.first?.usedPercent == 20
@@ -835,13 +948,12 @@ struct CodexReviewHostTests {
"config/read",
"model/list",
"account/login/start",
- "account/login/complete",
"account/read",
"account/rateLimits/read",
])
}
- @Test func liveStoreAddAccountSetupFailureRecordsAuthenticationFailure() async throws {
+ @Test func liveStoreAddAccountUsesExternalBrowserWhenNativeSessionFactoryFails() async throws {
let homeURL = try temporaryHome()
let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true)
try writeRegistry(
@@ -849,7 +961,7 @@ struct CodexReviewHostTests {
activeAccountKey: "active@example.com",
accounts: ["active@example.com"]
)
- let mainTransport = FakeJSONRPCTransport()
+ let mainTransport = FakeCodexAppServerTransport()
try await mainTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
try await mainTransport.enqueue(
AppServerAPI.Account.Read.Response(account: .init(email: "active@example.com", planType: "pro")),
@@ -867,7 +979,7 @@ struct CodexReviewHostTests {
for: "config/read"
)
try await mainTransport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list")
- let loginTransport = FakeJSONRPCTransport()
+ let loginTransport = FakeCodexAppServerTransport()
try await loginTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
try await loginTransport.enqueue(
AppServerAPI.Account.Login.Response.chatgpt(
@@ -877,8 +989,12 @@ struct CodexReviewHostTests {
),
for: "account/login/start"
)
- try await loginTransport.enqueue(AppServerAPI.Account.Login.Cancel.Response(), for: "account/login/cancel")
+ try await loginTransport.enqueue(
+ AppServerAPI.Account.Login.Cancel.Response(),
+ for: "account/login/cancel"
+ )
var isolatedCodexHomeURL: URL?
+ let externalURLOpener = FakeExternalURLOpener()
let store = CodexReviewStore.makeLiveStoreForTesting(
environment: ["HOME": homeURL.path],
nativeAuthenticationConfiguration: .init(
@@ -889,6 +1005,7 @@ struct CodexReviewHostTests {
webAuthenticationSessionFactory: { _, _, _, _ in
throw CodexReviewAPI.Error.io("Authentication presentation failed.")
},
+ externalURLOpener: externalURLOpener.open,
transportFactory: { codexHomeURL in
if codexHomeURL == mainCodexHomeURL {
return mainTransport
@@ -902,25 +1019,23 @@ struct CodexReviewHostTests {
await store.start(forceRestartIfNeeded: true)
let previousFailureCount = store.auth.authenticationFailureCount
await store.addAccount()
- await loginTransport.waitForRequestCount(3)
+ await loginTransport.waitForRequestCount(2)
let resolvedIsolatedCodexHomeURL = try #require(isolatedCodexHomeURL)
- #expect(store.auth.authenticationFailureCount == previousFailureCount + 1)
- #expect(failedMessage(from: store.auth.phase) == "Authentication presentation failed.")
+ #expect(store.auth.authenticationFailureCount == previousFailureCount)
+ #expect(store.auth.isAuthenticating)
#expect(store.auth.selectedAccount?.accountKey == "active@example.com")
- await waitUntil {
- FileManager.default.fileExists(atPath: resolvedIsolatedCodexHomeURL.path) == false
- }
+ #expect(externalURLOpener.openedURLs == [URL(string: "https://example.com/auth")!])
+ await store.stop()
#expect(FileManager.default.fileExists(atPath: resolvedIsolatedCodexHomeURL.path) == false)
- #expect(await loginTransport.recordedRequests().map(\.method) == [
+ #expect(Array(await loginTransport.recordedRequests().map(\.method).prefix(2)) == [
"initialize",
"account/login/start",
- "account/login/cancel",
])
}
@Test func liveStoreIgnoresNonCodexRateLimitNotifications() async throws {
- let transport = FakeJSONRPCTransport()
+ let transport = FakeCodexAppServerTransport()
try await transport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
try await transport.enqueue(
AppServerAPI.Account.Read.Response(account: .init(email: "active@example.com", planType: "pro")),
@@ -983,7 +1098,7 @@ struct CodexReviewHostTests {
)
try writeSavedAccountAuth(homeURL: homeURL, accountKey: "second@example.com")
- let firstTransport = FakeJSONRPCTransport()
+ let firstTransport = FakeCodexAppServerTransport()
try await firstTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
try await firstTransport.enqueue(
AppServerAPI.Account.Read.Response(account: .init(email: "first@example.com", planType: "pro")),
@@ -1005,7 +1120,7 @@ struct CodexReviewHostTests {
try await firstTransport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-first"), for: "review/start")
try await firstTransport.enqueue(EmptyResponse(), for: "turn/interrupt")
- let secondTransport = FakeJSONRPCTransport()
+ let secondTransport = FakeCodexAppServerTransport()
try await secondTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
try await secondTransport.enqueue(
AppServerAPI.Account.Read.Response(account: .init(email: "second@example.com", planType: "plus")),
@@ -1039,9 +1154,9 @@ struct CodexReviewHostTests {
sessionID: "session-1",
request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
)
- await waitUntil { store.jobs.first?.core.run.turnID == "turn-first" }
+ await waitUntil { store.reviewRuns.first?.core.run.turnID == "turn-first" }
- try await store.switchAccount(CodexAccount(email: "second@example.com"))
+ try await store.switchAccount(CodexReviewKit.CodexReviewAccount(email: "second@example.com"))
let result = try await reviewRead
await secondTransport.waitForRequestCount(2)
await firstTransport.waitForRequestCount(8)
@@ -1062,7 +1177,7 @@ struct CodexReviewHostTests {
accounts: ["active@example.com"]
)
- let firstTransport = FakeJSONRPCTransport()
+ let firstTransport = FakeCodexAppServerTransport()
try await firstTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
try await firstTransport.enqueue(
AppServerAPI.Account.Read.Response(account: .init(email: "active@example.com", planType: "pro")),
@@ -1086,7 +1201,7 @@ struct CodexReviewHostTests {
try await firstTransport.enqueue(EmptyResponse(), for: "turn/interrupt")
try await firstTransport.enqueue(EmptyResponse(), for: "account/logout")
- let secondTransport = FakeJSONRPCTransport()
+ let secondTransport = FakeCodexAppServerTransport()
try await secondTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
try await secondTransport.enqueue(AppServerAPI.Account.Read.Response(), for: "account/read")
try await secondTransport.enqueue(
@@ -1110,7 +1225,7 @@ struct CodexReviewHostTests {
sessionID: "session-1",
request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
)
- await waitUntil { store.jobs.first?.core.run.turnID == "turn-active" }
+ await waitUntil { store.reviewRuns.first?.core.run.turnID == "turn-active" }
await store.logout()
let result = try await reviewRead
@@ -1139,7 +1254,7 @@ struct CodexReviewHostTests {
try FileManager.default.createDirectory(at: mainCodexHomeURL, withIntermediateDirectories: true)
try originalAuth.write(to: mainCodexHomeURL.appendingPathComponent("auth.json"))
- let transport = FakeJSONRPCTransport()
+ let transport = FakeCodexAppServerTransport()
try await transport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
try await transport.enqueue(
AppServerAPI.Account.Read.Response(account: .init(email: "first@example.com", planType: "pro")),
@@ -1165,7 +1280,7 @@ struct CodexReviewHostTests {
await store.start(forceRestartIfNeeded: true)
await #expect(throws: (any Error).self) {
- try await store.switchAccount(CodexAccount(email: "second@example.com"))
+ try await store.switchAccount(CodexReviewKit.CodexReviewAccount(email: "second@example.com"))
}
#expect(store.auth.selectedAccount?.accountKey == "first@example.com")
@@ -1177,7 +1292,7 @@ struct CodexReviewHostTests {
let homeURL = try temporaryHome()
let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true)
let interruptGate = AsyncGate()
- let transport = FakeJSONRPCTransport()
+ let transport = FakeCodexAppServerTransport()
await transport.holdNext(method: "turn/interrupt", gate: interruptGate)
try await transport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
try await transport.enqueue(AppServerAPI.Account.Read.Response(), for: "account/read")
@@ -1189,10 +1304,11 @@ struct CodexReviewHostTests {
try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start")
try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1"), for: "review/start")
try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
+ try await transport.enqueue(EmptyResponse(), for: "thread/delete")
let store = CodexReviewStore.makeLiveStoreForTesting(
environment: ["HOME": homeURL.path],
webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession,
- mcpHTTPServerFactory: { store, _ in
+ mcpHTTPServerFactory: { store, _, _ in
CodexReviewMCPHTTPServer(
adapter: CodexReviewMCPServer(store: store),
configuration: .init(port: 0)
@@ -1212,7 +1328,7 @@ struct CodexReviewHostTests {
sessionID: sessionID,
request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
)
- await waitUntil { store.jobs.first?.core.run.turnID == "turn-1" }
+ await waitUntil { store.reviewRuns.first?.core.run.turnID == "turn-1" }
let stopTask = Task { @MainActor in
await store.stop()
@@ -1227,21 +1343,18 @@ struct CodexReviewHostTests {
#expect(interruptStarted)
#expect(methodsBeforeInterruptCompletes.contains("turn/interrupt"))
- #expect(methodsBeforeInterruptCompletes.contains("thread/backgroundTerminals/clean") == false)
#expect(methodsBeforeInterruptCompletes.contains("thread/delete") == false)
#expect(result.core.lifecycle.status == .cancelled)
let methods = await transport.recordedRequests().map(\.method)
let interruptIndex = try #require(methods.firstIndex(of: "turn/interrupt"))
- let cleanupIndex = try #require(methods.firstIndex(of: "thread/backgroundTerminals/clean"))
let deleteIndex = try #require(methods.firstIndex(of: "thread/delete"))
- #expect(interruptIndex < cleanupIndex)
#expect(interruptIndex < deleteIndex)
}
@Test func liveStoreStopBoundsStuckReviewCancellationCleanup() async throws {
let homeURL = try temporaryHome()
let interruptGate = AsyncGate()
- let transport = FakeJSONRPCTransport()
+ let transport = FakeCodexAppServerTransport()
await transport.holdNext(method: "turn/interrupt", gate: interruptGate)
try await transport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
try await transport.enqueue(AppServerAPI.Account.Read.Response(), for: "account/read")
@@ -1266,7 +1379,7 @@ struct CodexReviewHostTests {
request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
)
}
- await waitUntil { store.jobs.first?.core.run.turnID == "turn-1" }
+ await waitUntil { store.reviewRuns.first?.core.run.turnID == "turn-1" }
let startedAt = Date()
await store.stop()
@@ -1280,15 +1393,10 @@ struct CodexReviewHostTests {
#expect(await transport.recordedRequests().map(\.method).contains("turn/interrupt"))
}
- @Test func liveStoreStopDrainsRecoveryWaitingWorkerCleanupBeforeDroppingBackend() async throws {
+ @Test func liveStoreStopCleansRecoveryWaitingReviewWithoutAppServerCleanup() async throws {
let homeURL = try temporaryHome()
- let cleanupGate = AsyncGate()
let networkMonitor = ManualCodexReviewNetworkMonitor()
- let transport = FakeJSONRPCTransport()
- await transport.holdNextIgnoringCancellation(
- method: "thread/backgroundTerminals/clean",
- gate: cleanupGate
- )
+ let transport = FakeCodexAppServerTransport()
try await transport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
try await transport.enqueue(AppServerAPI.Account.Read.Response(), for: "account/read")
try await transport.enqueue(
@@ -1304,7 +1412,10 @@ struct CodexReviewHostTests {
AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "review-thread-1"),
for: "review/start"
)
+ try await transport.enqueueThreadResume(.init(id: "review-thread-1"))
try await transport.enqueue(EmptyResponse(), for: "turn/interrupt")
+ try await transport.enqueue(EmptyResponse(), for: "thread/delete")
+ try await transport.enqueue(EmptyResponse(), for: "thread/delete")
let store = CodexReviewStore.makeLiveStoreForTesting(
environment: ["HOME": homeURL.path],
webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession,
@@ -1315,13 +1426,14 @@ struct CodexReviewHostTests {
)
await store.start(forceRestartIfNeeded: true)
+ await transport.waitForNotificationStreamCount(1)
let reviewRead = Task { @MainActor in
try await store.startReview(
sessionID: "session-1",
request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
)
}
- try #require(await waitUntil(timeout: .seconds(2)) { store.jobs.first?.core.run.turnID == "turn-1" })
+ try #require(await waitUntil(timeout: .seconds(2)) { store.reviewRuns.first?.core.run.turnID == "turn-1" })
networkMonitor.yield(.init(status: .unsatisfied))
try #require(await waitUntil(timeout: .seconds(2)) {
@@ -1333,26 +1445,19 @@ struct CodexReviewHostTests {
await store.stop()
await stopFinished.complete()
}
- let cleanupStarted = await waitUntil(timeout: .seconds(2)) {
- await transport.recordedRequests().map(\.method).contains("thread/backgroundTerminals/clean")
- }
- let stoppedBeforeCleanupUnblocked = await waitUntil(timeout: .milliseconds(100)) {
- await stopFinished.isCompleted()
- }
- await cleanupGate.open()
await stopTask.value
let result = try await reviewRead.value
+ let methods = await transport.recordedRequests().map(\.method)
- #expect(cleanupStarted)
- #expect(stoppedBeforeCleanupUnblocked == false)
+ #expect(await stopFinished.isCompleted())
#expect(result.core.lifecycle.status == .cancelled)
- let methods = await transport.recordedRequests().map(\.method)
- #expect(methods.contains("thread/delete"))
+ #expect(methods.contains("turn/interrupt"))
+ #expect(methods.filter { $0 == "thread/delete" }.count == 2)
}
@Test func liveStoreMarksRuntimeFailedWhenAppServerNotificationStreamCloses() async throws {
let homeURL = try temporaryHome()
- let transport = FakeJSONRPCTransport()
+ let transport = FakeCodexAppServerTransport()
try await transport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
try await transport.enqueue(AppServerAPI.Account.Read.Response(), for: "account/read")
try await transport.enqueue(
@@ -1368,7 +1473,7 @@ struct CodexReviewHostTests {
await store.start(forceRestartIfNeeded: true)
await transport.waitForNotificationStreamCount(1)
- await transport.finishNotificationStreams(throwing: JSONRPC.Error.closed)
+ await transport.finishNotificationStreams(throwing: TestTransportClosedError())
await waitUntil {
if case .failed = store.serverState {
return true
@@ -1392,7 +1497,7 @@ struct CodexReviewHostTests {
activeAccountKey: "active@example.com",
accounts: ["active@example.com"]
)
- let mainTransport = FakeJSONRPCTransport()
+ let mainTransport = FakeCodexAppServerTransport()
try await mainTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
try await mainTransport.enqueue(
AppServerAPI.Account.Read.Response(account: .init(email: "active@example.com", planType: "pro")),
@@ -1403,7 +1508,7 @@ struct CodexReviewHostTests {
for: "config/read"
)
try await mainTransport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list")
- let loginTransport = FakeJSONRPCTransport()
+ let loginTransport = FakeCodexAppServerTransport()
try await loginTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
try await loginTransport.enqueue(
AppServerAPI.Account.Login.Response.chatgpt(
@@ -1413,7 +1518,7 @@ struct CodexReviewHostTests {
),
for: "account/login/start"
)
- let sessions = FakeWebAuthenticationSessions()
+ let externalURLOpener = FakeExternalURLOpener()
var isolatedCodexHomeURL: URL?
let store = CodexReviewStore.makeLiveStoreForTesting(
environment: ["HOME": homeURL.path],
@@ -1422,7 +1527,8 @@ struct CodexReviewHostTests {
browserSessionPolicy: .ephemeral,
presentationAnchorProvider: { NSWindow() }
),
- webAuthenticationSessionFactory: sessions.makeSession,
+ webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession,
+ externalURLOpener: externalURLOpener.open,
transportFactory: { codexHomeURL in
if codexHomeURL == mainCodexHomeURL {
return mainTransport
@@ -1436,30 +1542,21 @@ struct CodexReviewHostTests {
await store.start(forceRestartIfNeeded: true)
await mainTransport.waitForNotificationStreamCount(1)
await store.addAccount()
- let session = await sessions.waitForSession()
- await session.waitUntilWaitingForCallback()
let resolvedIsolatedCodexHomeURL = try #require(isolatedCodexHomeURL)
#expect(FileManager.default.fileExists(atPath: resolvedIsolatedCodexHomeURL.path))
+ #expect(externalURLOpener.openedURLs == [URL(string: "https://example.com/auth")!])
- await mainTransport.finishNotificationStreams(throwing: JSONRPC.Error.closed)
+ await mainTransport.finishNotificationStreams(throwing: TestTransportClosedError())
await waitUntil {
if case .failed = store.serverState {
return true
}
return false
}
- await waitUntil {
- await loginTransport.isClosedForTesting()
- }
-
- #expect(await loginTransport.isClosedForTesting())
await waitUntil {
FileManager.default.fileExists(atPath: resolvedIsolatedCodexHomeURL.path) == false
}
#expect(FileManager.default.fileExists(atPath: resolvedIsolatedCodexHomeURL.path) == false)
- await #expect(throws: JSONRPC.Error.closed) {
- _ = try await loginTransport.send(JSONRPC.Request(id: 99, method: "ping", params: Data()))
- }
}
@Test func liveStoreRemovingActiveAccountClearsSharedAuthAndRestartsSignedOutRuntime() async throws {
@@ -1473,7 +1570,7 @@ struct CodexReviewHostTests {
try Data("{\"tokens\":{\"id_token\":\"test\"}}".utf8)
.write(to: mainCodexHomeURL.appendingPathComponent("auth.json"))
- let firstTransport = FakeJSONRPCTransport()
+ let firstTransport = FakeCodexAppServerTransport()
try await firstTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
try await firstTransport.enqueue(
AppServerAPI.Account.Read.Response(account: .init(email: "active@example.com", planType: "pro")),
@@ -1493,7 +1590,7 @@ struct CodexReviewHostTests {
)
try await firstTransport.enqueue(EmptyResponse(), for: "account/logout")
- let secondTransport = FakeJSONRPCTransport()
+ let secondTransport = FakeCodexAppServerTransport()
try await secondTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
try await secondTransport.enqueue(AppServerAPI.Account.Read.Response(), for: "account/read")
try await secondTransport.enqueue(
@@ -1542,7 +1639,7 @@ struct CodexReviewHostTests {
transportFactory: { codexHomeURL in
isolatedCodexHomeURL = codexHomeURL
try FileManager.default.createDirectory(at: codexHomeURL, withIntermediateDirectories: true)
- return FakeJSONRPCTransport()
+ return FakeCodexAppServerTransport()
}
)
@@ -1561,7 +1658,7 @@ struct CodexReviewHostTests {
activeAccountKey: "active@example.com",
accounts: ["active@example.com"]
)
- let mainTransport = FakeJSONRPCTransport()
+ let mainTransport = FakeCodexAppServerTransport()
try await mainTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
try await mainTransport.enqueue(
AppServerAPI.Account.Read.Response(account: .init(email: "active@example.com", planType: "pro")),
@@ -1572,10 +1669,11 @@ struct CodexReviewHostTests {
for: "config/read"
)
try await mainTransport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list")
- let loginTransport = FakeJSONRPCTransport()
+ let loginTransport = FakeCodexAppServerTransport()
try await loginTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
await loginTransport.enqueueFailure(
- .responseError(code: -32603, message: "login unavailable"),
+ code: -32603,
+ message: "login unavailable",
for: "account/login/start"
)
var isolatedCodexHomeURL: URL?
@@ -1605,7 +1703,7 @@ struct CodexReviewHostTests {
#expect(FileManager.default.fileExists(atPath: resolvedIsolatedCodexHomeURL.path) == false)
}
- @Test func liveStoreClosesIsolatedLoginRuntimeWhenLoginCompletionFails() async throws {
+ @Test func liveStoreClosesIsolatedLoginRuntimeWhenLoginCompletionNotificationFails() async throws {
let homeURL = try temporaryHome()
let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true)
try writeRegistry(
@@ -1613,7 +1711,7 @@ struct CodexReviewHostTests {
activeAccountKey: "active@example.com",
accounts: ["active@example.com"]
)
- let mainTransport = FakeJSONRPCTransport()
+ let mainTransport = FakeCodexAppServerTransport()
try await mainTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
try await mainTransport.enqueue(
AppServerAPI.Account.Read.Response(account: .init(email: "active@example.com", planType: "pro")),
@@ -1624,7 +1722,7 @@ struct CodexReviewHostTests {
for: "config/read"
)
try await mainTransport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list")
- let loginTransport = FakeJSONRPCTransport()
+ let loginTransport = FakeCodexAppServerTransport()
try await loginTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize")
try await loginTransport.enqueue(
AppServerAPI.Account.Login.Response.chatgpt(
@@ -1634,11 +1732,7 @@ struct CodexReviewHostTests {
),
for: "account/login/start"
)
- await loginTransport.enqueueFailure(
- .responseError(code: -32603, message: "login completion failed"),
- for: "account/login/complete"
- )
- let sessions = FakeWebAuthenticationSessions()
+ let externalURLOpener = FakeExternalURLOpener()
var isolatedCodexHomeURL: URL?
let store = CodexReviewStore.makeLiveStoreForTesting(
environment: ["HOME": homeURL.path],
@@ -1647,7 +1741,8 @@ struct CodexReviewHostTests {
browserSessionPolicy: .ephemeral,
presentationAnchorProvider: { NSWindow() }
),
- webAuthenticationSessionFactory: sessions.makeSession,
+ webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession,
+ externalURLOpener: externalURLOpener.open,
transportFactory: { codexHomeURL in
if codexHomeURL == mainCodexHomeURL {
return mainTransport
@@ -1660,31 +1755,39 @@ struct CodexReviewHostTests {
await store.start(forceRestartIfNeeded: true)
await store.addAccount()
- let session = await sessions.waitForSession()
- await session.waitUntilWaitingForCallback()
- session.complete(with: URL(string: "lynnpd.CodexReviewMonitor.auth://callback?code=1")!)
- await loginTransport.waitForRequestCount(3)
+ await loginTransport.waitForNotificationStreamCount(1)
+ #expect(externalURLOpener.openedURLs == [URL(string: "https://example.com/auth")!])
+ try await loginTransport.emitServerNotification(
+ method: "account/login/completed",
+ params: TestLoginCompletedNotification(
+ loginID: "login-2",
+ success: false,
+ error: "login completion failed"
+ )
+ )
let resolvedIsolatedCodexHomeURL = try #require(isolatedCodexHomeURL)
await waitUntil { failedMessage(from: store.auth.phase) == "login completion failed" }
+ await waitUntil {
+ FileManager.default.fileExists(atPath: resolvedIsolatedCodexHomeURL.path) == false
+ }
#expect(FileManager.default.fileExists(atPath: resolvedIsolatedCodexHomeURL.path) == false)
#expect(await loginTransport.recordedRequests().map(\.method) == [
"initialize",
"account/login/start",
- "account/login/complete",
])
}
@Test func liveStoreRemovesOnlyEncodedSavedAccountDirectory() async throws {
let homeURL = try temporaryHome()
let codexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true)
- let account = CodexAccount(email: "../outside@example.com")
+ let account = CodexReviewKit.CodexReviewAccount(email: "../outside@example.com")
let rawFallbackDirectoryURL = codexHomeURL.appendingPathComponent("outside@example.com", isDirectory: true)
try FileManager.default.createDirectory(at: rawFallbackDirectoryURL, withIntermediateDirectories: true)
let store = CodexReviewStore.makeLiveStoreForTesting(
environment: ["HOME": homeURL.path],
webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession,
- transport: FakeJSONRPCTransport()
+ transport: FakeCodexAppServerTransport()
)
store.auth.applyPersistedAccountStates([savedAccountPayload(from: account)])
@@ -1701,8 +1804,8 @@ struct CodexReviewHostTests {
let sentinelURL = codexHomeURL.appendingPathComponent("sentinel.txt")
try Data("keep".utf8).write(to: sentinelURL)
- let dotAccount = CodexAccount(email: ".")
- let dotDotAccount = CodexAccount(email: "..")
+ let dotAccount = CodexReviewKit.CodexReviewAccount(email: ".")
+ let dotDotAccount = CodexReviewKit.CodexReviewAccount(email: "..")
let dotDirectoryURL = accountsURL.appendingPathComponent("%2E", isDirectory: true)
let dotDotDirectoryURL = accountsURL.appendingPathComponent("%2E%2E", isDirectory: true)
try FileManager.default.createDirectory(at: dotDirectoryURL, withIntermediateDirectories: true)
@@ -1710,7 +1813,7 @@ struct CodexReviewHostTests {
let store = CodexReviewStore.makeLiveStoreForTesting(
environment: ["HOME": homeURL.path],
webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession,
- transport: FakeJSONRPCTransport()
+ transport: FakeCodexAppServerTransport()
)
store.auth.applyPersistedAccountStates([
savedAccountPayload(from: dotAccount),
@@ -1764,6 +1867,357 @@ private struct TestRateLimitsUpdatedNotification: Encodable, Sendable {
var rateLimits: AppServerAPI.Account.RateLimits.Snapshot
}
+private struct TestTransportClosedError: LocalizedError, Equatable, Sendable {
+ var errorDescription: String? {
+ "JSON-RPC transport is closed."
+ }
+}
+
+private struct EmptyResponse: Codable, Equatable, Sendable {
+ init() {}
+}
+
+private enum AppServerAPI {
+ enum Initialize {
+ struct Response: Codable, Equatable, Sendable {
+ var codexHome: String?
+ var userAgent: String?
+
+ init(codexHome: String? = nil, userAgent: String? = nil) {
+ self.codexHome = codexHome
+ self.userAgent = userAgent
+ }
+ }
+ }
+
+ enum Config {
+ enum Read {
+ struct Response: Codable, Equatable, Sendable {
+ var config: Snapshot
+ }
+ }
+
+ struct Snapshot: Codable, Equatable, Sendable {
+ var model: String?
+ var reviewModel: String?
+ var modelReasoningEffort: String?
+ var serviceTier: String?
+
+ enum CodingKeys: String, CodingKey {
+ case model
+ case reviewModel = "review_model"
+ case modelReasoningEffort = "model_reasoning_effort"
+ case serviceTier = "service_tier"
+ }
+
+ 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
+ }
+ }
+ }
+
+ enum Model {
+ enum List {
+ struct Response: Codable, Equatable, Sendable {
+ var data: [CodexModel]
+ var nextCursor: String?
+
+ init(data: [CodexModel], nextCursor: String? = nil) {
+ self.data = data
+ self.nextCursor = nextCursor
+ }
+ }
+ }
+ }
+
+ enum Thread {
+ enum Start {
+ struct Response: Codable, Equatable, Sendable {
+ var threadID: String
+ var model: String?
+
+ enum CodingKeys: String, CodingKey {
+ case thread
+ case model
+ }
+
+ private struct Thread: Codable, Equatable, Sendable {
+ var id: String
+ }
+
+ init(threadID: String, model: String? = nil) {
+ self.threadID = threadID
+ self.model = model
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ threadID = try container.decode(Thread.self, forKey: .thread).id
+ model = try container.decodeIfPresent(String.self, forKey: .model)
+ }
+
+ 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)
+ }
+ }
+ }
+ }
+
+ enum Review {
+ enum Start {
+ struct Response: Codable, Equatable, Sendable {
+ var turnID: String
+ var reviewThreadID: String?
+
+ enum CodingKeys: String, CodingKey {
+ case turn
+ case reviewThreadID = "reviewThreadId"
+ }
+
+ private struct Turn: Codable, Equatable, Sendable {
+ var id: String
+ }
+
+ init(turnID: String, reviewThreadID: String? = nil) {
+ self.turnID = turnID
+ self.reviewThreadID = reviewThreadID
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ turnID = try container.decode(Turn.self, forKey: .turn).id
+ reviewThreadID = try container.decodeIfPresent(String.self, forKey: .reviewThreadID)
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(Turn(id: turnID), forKey: .turn)
+ try container.encodeIfPresent(reviewThreadID, forKey: .reviewThreadID)
+ }
+ }
+ }
+ }
+
+ enum Account {
+ enum Read {
+ struct Response: Codable, Equatable, Sendable {
+ var account: Snapshot?
+ var requiresOpenAIAuth: Bool
+
+ enum CodingKeys: String, CodingKey {
+ case account
+ case requiresOpenAIAuth = "requiresOpenaiAuth"
+ }
+
+ init(account: Snapshot? = nil, requiresOpenAIAuth: Bool = false) {
+ self.account = account
+ self.requiresOpenAIAuth = requiresOpenAIAuth
+ }
+ }
+ }
+
+ struct Snapshot: Codable, Equatable, Sendable {
+ var type: String
+ var email: String?
+ var planType: String?
+
+ init(type: String = "chatgpt", email: String? = nil, planType: String? = nil) {
+ self.type = type
+ self.email = email
+ self.planType = planType
+ }
+ }
+
+ enum RateLimits {
+ struct Response: Codable, Equatable, Sendable {
+ var rateLimits: Snapshot
+ var rateLimitsByLimitID: [String: Snapshot]?
+
+ enum CodingKeys: String, CodingKey {
+ case rateLimits
+ case rateLimitsByLimitID = "rateLimitsByLimitId"
+ }
+
+ init(rateLimits: Snapshot, rateLimitsByLimitID: [String: Snapshot]? = nil) {
+ self.rateLimits = rateLimits
+ self.rateLimitsByLimitID = rateLimitsByLimitID
+ }
+ }
+
+ struct Snapshot: Codable, Equatable, Sendable {
+ var limitID: String?
+ var primary: Window?
+ var secondary: Window?
+ var planType: String?
+
+ enum CodingKeys: String, CodingKey {
+ case limitID = "limitId"
+ case primary
+ case secondary
+ case planType
+ }
+
+ init(
+ limitID: String? = nil,
+ primary: Window? = nil,
+ secondary: Window? = nil,
+ planType: String? = nil
+ ) {
+ self.limitID = limitID
+ self.primary = primary
+ self.secondary = secondary
+ self.planType = planType
+ }
+ }
+
+ struct Window: Codable, Equatable, Sendable {
+ var usedPercent: Int
+ var windowDurationMins: Int?
+ var resetsAt: Int64?
+
+ init(usedPercent: Int, windowDurationMins: Int? = nil, resetsAt: Int64? = nil) {
+ self.usedPercent = usedPercent
+ self.windowDurationMins = windowDurationMins
+ self.resetsAt = resetsAt
+ }
+ }
+ }
+
+ enum Login {
+ struct Params: Codable, Equatable, Sendable {
+ var type: String
+ var apiKey: String?
+ var codexStreamlinedLogin: Bool
+ var nativeWebAuthentication: NativeWebAuthentication?
+
+ init(
+ type: String = "chatgpt",
+ apiKey: String? = nil,
+ codexStreamlinedLogin: Bool = true,
+ nativeWebAuthentication: NativeWebAuthentication? = nil
+ ) {
+ self.type = type
+ self.apiKey = apiKey
+ self.codexStreamlinedLogin = codexStreamlinedLogin
+ self.nativeWebAuthentication = nativeWebAuthentication
+ }
+ }
+
+ struct NativeWebAuthentication: Codable, Equatable, Sendable {
+ var callbackURLScheme: String
+
+ enum CodingKeys: String, CodingKey {
+ case callbackURLScheme = "callbackUrlScheme"
+ }
+ }
+
+ enum Complete {
+ struct Params: Codable, Equatable, Sendable {
+ var loginID: String
+ var callbackURL: String
+
+ enum CodingKeys: String, CodingKey {
+ case loginID = "loginId"
+ case callbackURL = "callbackUrl"
+ }
+ }
+
+ struct Response: Codable, Equatable, Sendable {
+ init() {}
+ }
+ }
+
+ enum Response: Codable, Equatable, Sendable {
+ case apiKey
+ case chatgpt(
+ loginID: String,
+ authURL: String,
+ nativeWebAuthentication: NativeWebAuthentication?
+ )
+ case chatgptDeviceCode(loginID: String, verificationURL: String, userCode: String)
+ case chatgptAuthTokens
+
+ private enum CodingKeys: String, CodingKey {
+ case type
+ case loginID = "loginId"
+ case authURL = "authUrl"
+ case nativeWebAuthentication
+ case verificationURL = "verificationUrl"
+ case userCode
+ }
+
+ 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),
+ nativeWebAuthentication: try container.decodeIfPresent(
+ NativeWebAuthentication.self,
+ forKey: .nativeWebAuthentication
+ )
+ )
+ 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)"
+ )
+ }
+ }
+
+ 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, let nativeWebAuthentication):
+ try container.encode("chatgpt", forKey: .type)
+ try container.encode(loginID, forKey: .loginID)
+ try container.encode(authURL, forKey: .authURL)
+ try container.encodeIfPresent(nativeWebAuthentication, forKey: .nativeWebAuthentication)
+ 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)
+ }
+ }
+ }
+
+ enum Cancel {
+ struct Response: Codable, Equatable, Sendable {
+ init() {}
+ }
+ }
+
+ }
+ }
+}
+
@MainActor
private final class FakeWebAuthenticationSessions {
private var session: FakeWebAuthenticationSession?
diff --git a/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift b/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift
new file mode 100644
index 00000000..6a46c045
--- /dev/null
+++ b/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift
@@ -0,0 +1,112 @@
+import Foundation
+import Testing
+@testable import CodexReviewKit
+
+@Suite("review backend event session")
+struct ReviewBackendEventSessionTests {
+ @Test func emitsEventsAndRecordsLifecycleCallbacks() async throws {
+ let recorder = ReviewBackendEventSessionRecorder()
+ let session = ReviewBackendEventSession(
+ run: makeRun(),
+ callbacks: .init(
+ recordTurnStarted: { turnID in
+ await recorder.recordTurnStarted(turnID)
+ },
+ recordFinished: { run, metrics in
+ await recorder.recordFinished(run: run, metrics: metrics)
+ }
+ )
+ )
+ let attempt = await session.attempt()
+
+ await session.receive(
+ [
+ .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5"),
+ .completed(finalReview: "No issues found."),
+ ], controlThreadID: "review-thread")
+
+ #expect(
+ try await nextEvent(from: attempt.events)
+ == .started(
+ turnID: "turn-1",
+ reviewThreadID: "review-thread",
+ model: "gpt-5"
+ ))
+ #expect(
+ try await nextEvent(from: attempt.events)
+ == .completed(finalReview: "No issues found."))
+ #expect(await recorder.startedTurnIDs() == ["turn-1"])
+ #expect(await recorder.finishedRun() == makeRun())
+
+ let metrics = await session.metricsSnapshot()
+ #expect(metrics.routed == 1)
+ #expect(metrics.decoded == 1)
+ #expect(metrics.emitted == 2)
+ #expect(metrics.ignored == 0)
+ #expect(metrics.firstEventLatencyMs != nil)
+ #expect(metrics.terminalLatencyMs != nil)
+ #expect(await recorder.finishedMetrics() == metrics)
+ }
+}
+
+private actor ReviewBackendEventSessionRecorder {
+ private var turnIDs: [String] = []
+ private var completedRun: CodexReviewBackendModel.Review.Run?
+ private var completedMetrics: ReviewBackendEventSessionMetrics?
+
+ func recordTurnStarted(_ turnID: String) {
+ turnIDs.append(turnID)
+ }
+
+ func recordFinished(
+ run: CodexReviewBackendModel.Review.Run,
+ metrics: ReviewBackendEventSessionMetrics
+ ) {
+ completedRun = run
+ completedMetrics = metrics
+ }
+
+ func startedTurnIDs() -> [String] {
+ turnIDs
+ }
+
+ func finishedRun() -> CodexReviewBackendModel.Review.Run? {
+ completedRun
+ }
+
+ func finishedMetrics() -> ReviewBackendEventSessionMetrics? {
+ completedMetrics
+ }
+}
+
+private enum ReviewBackendEventSessionTestTimeout: Error {
+ case timedOut
+}
+
+private func nextEvent(
+ from mailbox: BackendReviewEventMailbox,
+ timeout: Duration = .seconds(1)
+) async throws -> CodexReviewBackendModel.Review.Event? {
+ try await withThrowingTaskGroup(of: CodexReviewBackendModel.Review.Event?.self) { group in
+ group.addTask {
+ try await mailbox.next()
+ }
+ group.addTask {
+ try await Task.sleep(for: timeout)
+ throw ReviewBackendEventSessionTestTimeout.timedOut
+ }
+ let event = try await group.next()
+ group.cancelAll()
+ return event ?? nil
+ }
+}
+
+private func makeRun() -> CodexReviewBackendModel.Review.Run {
+ .init(
+ attemptID: "attempt-1",
+ threadID: "thread-1",
+ turnID: "turn-1",
+ reviewThreadID: "review-thread",
+ model: "gpt-5"
+ )
+}
diff --git a/Tests/CodexReviewKitTests/Application/ReviewObservationAwaiterTests.swift b/Tests/CodexReviewKitTests/Application/ReviewObservationAwaiterTests.swift
new file mode 100644
index 00000000..f71fb50d
--- /dev/null
+++ b/Tests/CodexReviewKitTests/Application/ReviewObservationAwaiterTests.swift
@@ -0,0 +1,61 @@
+import Foundation
+import Testing
+@testable import CodexReviewKit
+
+@MainActor
+@Suite("review observation awaiter")
+struct ReviewObservationAwaiterTests {
+ @Test func resumesWhenRunReachesTerminalState() async throws {
+ let run = makeRunningRun()
+
+ let task = Task { @MainActor in
+ await ReviewObservationAwaiter.waitUntilTerminal(
+ run: run,
+ timeout: .seconds(1)
+ )
+ }
+ await Task.yield()
+
+ run.updateStateForTesting(status: .succeeded, summary: "Done")
+
+ let result = await task.value
+ #expect(result)
+ }
+
+ @Test func resumesWhenRunCancellationReachesTerminalState() async throws {
+ let run = makeRunningRun()
+
+ let task = Task { @MainActor in
+ await ReviewObservationAwaiter.waitUntilTerminal(
+ run: run,
+ timeout: .seconds(1)
+ )
+ }
+ await Task.yield()
+
+ run.updateStateForTesting(status: .cancelled, summary: "Stop")
+
+ let result = await task.value
+ #expect(result)
+ }
+
+ @Test func returnsFalseOnTimeout() async throws {
+ let run = makeRunningRun()
+
+ let result = await ReviewObservationAwaiter.waitUntilTerminal(
+ run: run,
+ timeout: .milliseconds(10)
+ )
+
+ #expect(result == false)
+ }
+
+ private func makeRunningRun() -> ReviewRunRecord {
+ ReviewRunRecord.makeForTesting(
+ id: "run-awaiter",
+ targetSummary: "Uncommitted changes",
+ status: .running,
+ summary: "Running review."
+ )
+ }
+}
diff --git a/Tests/CodexReviewTests/CodexReviewAuthModelTests.swift b/Tests/CodexReviewKitTests/CodexReviewAuthModelTests.swift
similarity index 75%
rename from Tests/CodexReviewTests/CodexReviewAuthModelTests.swift
rename to Tests/CodexReviewKitTests/CodexReviewAuthModelTests.swift
index 3dbf7f38..5badb836 100644
--- a/Tests/CodexReviewTests/CodexReviewAuthModelTests.swift
+++ b/Tests/CodexReviewKitTests/CodexReviewAuthModelTests.swift
@@ -1,15 +1,15 @@
import Testing
-@_spi(Testing) @testable import CodexReview
+@_spi(Testing) @testable import CodexReviewKit
@Suite("Codex review auth model")
@MainActor
struct CodexReviewAuthModelTests {
@Test func persistedSnapshotReusesDetachedSelectedAccountWithSameKey() {
let auth = CodexReviewAuthModel()
- let detachedAccount = CodexAccount(email: "new@example.com", planType: "pro")
+ let detachedAccount = CodexReviewAccount(email: "new@example.com", planType: "pro")
auth.updateCurrentAccount(detachedAccount)
- let persistedPayload = savedAccountPayload(from: CodexAccount(email: "new@example.com", planType: "team"))
+ let persistedPayload = savedAccountPayload(from: CodexReviewAccount(email: "new@example.com", planType: "team"))
auth.applyPersistedAccountStates([persistedPayload])
#expect(auth.persistedAccounts.count == 1)
@@ -20,9 +20,9 @@ struct CodexReviewAuthModelTests {
@Test func switchRequestRequiresDifferentPersistedAccount() {
let auth = CodexReviewAuthModel()
- let selectedAccount = CodexAccount(email: "selected@example.com", planType: "pro")
- let otherAccount = CodexAccount(email: "other@example.com", planType: "plus")
- let detachedAccount = CodexAccount(email: "detached@example.com", planType: "team")
+ let selectedAccount = CodexReviewAccount(email: "selected@example.com", planType: "pro")
+ let otherAccount = CodexReviewAccount(email: "other@example.com", planType: "plus")
+ let detachedAccount = CodexReviewAccount(email: "detached@example.com", planType: "team")
auth.applyPersistedAccountStates([
savedAccountPayload(from: selectedAccount),
savedAccountPayload(from: otherAccount),
diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift
new file mode 100644
index 00000000..6390d2c3
--- /dev/null
+++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift
@@ -0,0 +1,1882 @@
+import Foundation
+import Testing
+@_spi(Testing) @testable import CodexReviewKit
+import CodexReviewTesting
+
+@Suite("Codex review store", .serialized)
+@MainActor
+struct CodexReviewStoreCommandTests {
+ @Test func reviewStartPublishesCompletedRunLifecycle() async throws {
+ let backend = FakeCodexReviewBackend()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ clock: .init(now: { Date(timeIntervalSince1970: 1) }),
+ idGenerator: .init(next: { "run-1" })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let result = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
+ )
+ await backend.yield(.completed(finalReview: "No issues found."))
+ let read = try await result
+
+ #expect(read.runID == "run-1")
+ #expect(read.core.lifecycle.status == .succeeded)
+ #expect(store.listReviews(sessionID: nil).items.map(\.runID) == ["run-1"])
+
+ let commands = await backend.recordedCommands()
+ #expect(
+ commands.contains(
+ .cleanupReview(
+ .init(
+ threadID: "thread-1",
+ turnID: "turn-1",
+ reviewThreadID: "review-thread-1"
+ ))))
+ }
+ }
+
+ @Test func boundedReviewStartReturnsRunningSnapshotAndCanBeAwaitedLater() async throws {
+ let backend = FakeCodexReviewBackend()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let result = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
+ waitTimeout: .milliseconds(20)
+ )
+ let running = try await result
+
+ #expect(running.runID == "run-1")
+ #expect(running.core.lifecycle.status == .running)
+
+ await backend.yield(.completed(finalReview: "No issues found."))
+ let final = try await store.awaitReview(
+ sessionID: "session-1",
+ runID: "run-1",
+ timeout: .seconds(1)
+ )
+
+ #expect(final.core.lifecycle.status == .succeeded)
+ }
+ }
+
+ @Test func awaitReviewReturnsWhenRunningRunCompletes() async throws {
+ let backend = FakeCodexReviewBackend()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let start = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
+ waitTimeout: .milliseconds(20)
+ )
+ _ = try await start
+
+ async let awaited = store.awaitReview(
+ sessionID: "session-1",
+ runID: "run-1",
+ timeout: .seconds(1)
+ )
+ await backend.yield(.completed(finalReview: "No issues found."))
+ let final = try await awaited
+
+ #expect(final.core.lifecycle.status == .succeeded)
+ }
+ }
+
+ @Test func awaitReviewReturnsWhenRunningRunIsCancelled() async throws {
+ let backend = FakeCodexReviewBackend()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let start = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
+ waitTimeout: .milliseconds(20)
+ )
+ _ = try await start
+
+ async let awaited = store.awaitReview(
+ sessionID: "session-1",
+ runID: "run-1",
+ timeout: .seconds(1)
+ )
+ _ = try await store.cancelReview(
+ runID: "run-1",
+ cancellation: .mcpClient(message: "Stop")
+ )
+ let final = try await awaited
+
+ #expect(final.core.lifecycle.status == .cancelled)
+ #expect(final.core.lifecycleMessage == "Stop")
+ }
+ }
+
+ @Test func awaitReviewReturnsCurrentSnapshotOnTimeout() async throws {
+ let backend = FakeCodexReviewBackend()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let start = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
+ waitTimeout: .milliseconds(20)
+ )
+ _ = try await start
+
+ let snapshot = try await store.awaitReview(
+ sessionID: "session-1",
+ runID: "run-1",
+ timeout: .milliseconds(10)
+ )
+
+ #expect(snapshot.core.lifecycle.status == .running)
+ }
+ }
+
+ @Test func awaitReviewReturnsWhenLocalTerminationUpdatesRunLifecycle() async throws {
+ let backend = FakeCodexReviewBackend()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let start = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
+ waitTimeout: .milliseconds(20)
+ )
+ _ = try await start
+
+ async let awaited = store.awaitReview(
+ sessionID: "session-1",
+ runID: "run-1",
+ timeout: .seconds(1)
+ )
+ await Task.yield()
+ store.terminateAllRunningReviewRunsLocally(
+ failureMessage: "Review runtime stopped."
+ )
+ let final = try await awaited
+
+ #expect(final.core.lifecycle.status == .failed)
+ #expect(final.core.lifecycleMessage == "Failed to cancel review: Review runtime stopped.")
+ }
+ }
+
+ @Test func forceStartWhileRunningInvokesBackendRestartPath() async {
+ let reviewBackend = FakeCodexReviewBackend()
+ let backend = TestingCodexReviewStoreBackend(reviewBackend: reviewBackend)
+ let store = CodexReviewStore.makeTestingStore(backend: backend)
+ await withStoreCommandTestCleanup(backend: reviewBackend, store: store) {
+ await store.start()
+ await store.start()
+ await store.start(forceRestartIfNeeded: true)
+
+ #expect(backend.startRequests == [false, true])
+ }
+ }
+
+ @Test func reviewStartPassesEffectiveSettingsModelToBackend() async throws {
+ let backend = FakeCodexReviewBackend()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(
+ reviewBackend: backend,
+ seed: .init(initialSettingsSnapshot: .init(fallbackModel: "gpt-5.5"))
+ ),
+ idGenerator: .init(next: { "run-1" })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let result = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
+ )
+ await backend.yield(.completed(finalReview: "No issues found."))
+ _ = try await result
+
+ let commands = await backend.recordedCommands()
+ let starts = commands.compactMap { command -> CodexReviewBackendModel.Review.Start? in
+ if case .startReview(let request) = command {
+ return request
+ }
+ return nil
+ }
+ #expect(starts.first?.model == "gpt-5.5")
+ }
+ }
+
+ @Test func reviewStartDoesNotNeedProgressEventsForLifecycle() async throws {
+ let backend = FakeCodexReviewBackend()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let result = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
+ )
+ let probe = StoreSnapshotProbe(store: store)
+ let runningSnapshot = try #require(await probe.waitUntilRunStatus(.running, runID: "run-1"))
+ #expect(runningSnapshot.run("run-1")?.summary == "Review started.")
+
+ await backend.yield(.completed(finalReview: "No issues found."))
+ let read = try await result
+ #expect(read.core.lifecycleMessage == "Review completed.")
+ }
+ }
+
+ @Test func newlyStartedReviewAppearsBeforeExistingRunsAcrossWorkspaces() async throws {
+ let backend = FakeCodexReviewBackend()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let first = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/old-project", target: .baseBranch("main"))
+ )
+ await backend.yield(.completed(finalReview: "No issues found."))
+ _ = try await first
+ await backend.finishEvents()
+
+ async let second = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/new-project", target: .uncommittedChanges)
+ )
+ await backend.yield(.completed(finalReview: "No issues found."))
+ _ = try await second
+
+ #expect(store.orderedReviewRuns.map(\.cwd) == ["/tmp/new-project", "/tmp/old-project"])
+ }
+ }
+
+ @Test func newlyStartedReviewAppearsBeforeExistingRunsInWorkspace() async throws {
+ let backend = FakeCodexReviewBackend()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let first = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
+ )
+ await backend.yield(.completed(finalReview: "No issues found."))
+ _ = try await first
+ await backend.finishEvents()
+
+ async let second = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
+ )
+ await backend.yield(.completed(finalReview: "No issues found."))
+ _ = try await second
+
+ #expect(
+ store.listReviews(cwd: "/tmp/project").items.map(\.targetSummary) == [
+ "Uncommitted changes",
+ "Base branch: main",
+ ])
+ }
+ }
+
+ @Test func runningReviewElapsedSecondsUsesInjectedClock() async throws {
+ let backend = FakeCodexReviewBackend()
+ let clock = MutableTestClock(Date(timeIntervalSince1970: 1))
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ clock: .init(now: { clock.now() }),
+ idGenerator: .init(next: { "run-1" })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let result = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
+ )
+ try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil)
+ clock.current = Date(timeIntervalSince1970: 13)
+
+ #expect(try store.readReview(runID: "run-1").elapsedSeconds == 12)
+
+ await backend.yield(.completed(finalReview: "No issues found."))
+ _ = try await result
+ }
+ }
+
+ @Test func newlyStartedReviewUsesSortOrderAboveCurrentMaximum() async throws {
+ let backend = FakeCodexReviewBackend()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ let existing = ReviewRunRecord.makeForTesting(
+ id: "run-existing",
+ cwd: "/tmp/project",
+ targetSummary: "Existing",
+ status: .succeeded,
+ summary: "Done"
+ )
+ store.loadForTesting(
+ serverState: .running,
+ reviewRuns: [existing]
+ )
+ store.reviewRun(id: "run-existing")?.sortOrder = 10
+
+ async let result = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
+ )
+ await backend.yield(.completed(finalReview: "No issues found."))
+ _ = try await result
+
+ #expect(store.listReviews(cwd: "/tmp/project").items.map(\.targetSummary).first == "Uncommitted changes")
+ }
+ }
+
+ @Test func cancelRunningReviewUsesBackendInterruptAndPublicState() async throws {
+ let backend = FakeCodexReviewBackend()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let result = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
+ )
+ try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil)
+ let cancel = try await store.cancelReview(
+ runID: "run-1",
+ cancellation: .mcpClient(message: "Stop")
+ )
+ await backend.yield(.cancelled("Stop"))
+ _ = try await result
+
+ #expect(cancel.cancelled)
+ #expect(try store.readReview(runID: "run-1").core.lifecycle.status == .cancelled)
+ let commands = await backend.recordedCommands()
+ #expect(
+ commands.contains(
+ .interruptReview(
+ .init(threadID: "thread-1", turnID: "turn-1", reviewThreadID: "review-thread-1"),
+ .init(message: "Stop")
+ )))
+ }
+ }
+
+ @Test func pendingCancellationIsNoLongerCancellable() async throws {
+ let backend = FakeCodexReviewBackend()
+ let interruptGate = AsyncGate()
+ await backend.holdInterruptReview(with: interruptGate)
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let result = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
+ )
+ try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil)
+
+ async let cancellation = store.cancelReview(
+ runID: "run-1",
+ cancellation: .mcpClient(message: "Stop")
+ )
+ try await backend.waitForInterruptReview(timeout: .seconds(2))
+
+ #expect(try store.readReview(runID: "run-1").cancellable == false)
+ #expect(store.listReviews().items.first?.cancellable == false)
+ #expect(store.hasCancellableReview(forChatID: "thread-1") == false)
+
+ let duplicate = try await store.cancelReview(
+ runID: "run-1",
+ cancellation: .mcpClient(message: "Stop again")
+ )
+ #expect(duplicate.cancelled == false)
+ #expect(duplicate.core.lifecycle.cancellation?.message == "Stop")
+
+ await interruptGate.open()
+ _ = try await cancellation
+ let read = try await result
+ #expect(read.core.lifecycle.status == .cancelled)
+ #expect(read.core.lifecycle.cancellation?.message == "Stop")
+ }
+ }
+
+ @Test func transientNetworkOutageDoesNotRecoverReview() async throws {
+ let backend = FakeCodexReviewBackend()
+ let networkMonitor = ManualCodexReviewNetworkMonitor()
+ let debounceGate = AsyncGate()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" }),
+ networkMonitor: networkMonitor,
+ networkRecoveryPolicy: .init(
+ outageDebounce: .seconds(10),
+ recoverySettle: .seconds(1),
+ sleep: { _ in await debounceGate.wait() }
+ )
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let result = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
+ )
+
+ networkMonitor.yield(.init(status: .unsatisfied))
+ networkMonitor.yield(.satisfied())
+ await debounceGate.open()
+
+ let attemptedRecovery = await waitUntil(timeout: .milliseconds(100)) {
+ let commands = await backend.recordedCommands()
+ return commands.contains { command in
+ if case .prepareReviewRestart = command {
+ true
+ } else {
+ false
+ }
+ }
+ }
+ #expect(attemptedRecovery == false)
+ let commands = await backend.recordedCommands()
+ #expect(
+ commands.contains { command in
+ if case .prepareReviewRestart = command {
+ true
+ } else {
+ false
+ }
+ } == false)
+
+ await backend.yield(.completed(finalReview: "No issues found."))
+ let read = try await result
+ #expect(read.core.lifecycle.status == .succeeded)
+ }
+ }
+
+ @Test func sustainedNetworkOutageInterruptsForRecoveryWithoutTerminalRun() async throws {
+ let backend = FakeCodexReviewBackend()
+ let networkMonitor = ManualCodexReviewNetworkMonitor()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" }),
+ networkMonitor: networkMonitor,
+ networkRecoveryPolicy: .init(sleep: { _ in })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let result = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
+ )
+
+ networkMonitor.yield(.init(status: .unsatisfied))
+ try await backend.waitForPrepareReviewRestart(timeout: .seconds(2))
+
+ let running = try store.readReview(runID: "run-1")
+ #expect(running.core.lifecycle.status == .running)
+ #expect(running.core.lifecycleMessage == "Network unavailable; waiting to reconnect.")
+ _ = try await store.cancelReview(runID: "run-1", cancellation: .mcpClient(message: "Stop"))
+ await backend.yield(.cancelled("Stop"))
+ _ = try await result
+ }
+ }
+
+ @Test func networkRecoveryRepeatedSatisfiedSnapshotsRestartAfterLatestSettle() async throws {
+ let initialRun = CodexReviewBackendModel.Review.Run(
+ threadID: "thread-1",
+ turnID: "turn-1",
+ reviewThreadID: "review-thread-1",
+ model: "gpt-5"
+ )
+ let recoveredRun = CodexReviewBackendModel.Review.Run(
+ attemptID: "attempt-recovered",
+ threadID: "thread-1",
+ turnID: "turn-2",
+ reviewThreadID: "review-thread-1",
+ model: "gpt-5"
+ )
+ let backend = FakeCodexReviewBackend(nextRun: initialRun)
+ await backend.setNextRecoveredRun(recoveredRun)
+ let networkMonitor = ManualCodexReviewNetworkMonitor()
+ let settleGate = AsyncGate()
+ let sleeper = ControlledTestSleeper(gate: settleGate)
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" }),
+ networkMonitor: networkMonitor,
+ networkRecoveryPolicy: .init(
+ outageDebounce: .seconds(10),
+ recoverySettle: .seconds(1),
+ sleep: { _ in await sleeper.sleep() }
+ )
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let result = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
+ )
+
+ networkMonitor.yield(.init(status: .unsatisfied))
+ try await backend.waitForPrepareReviewRestart(timeout: .seconds(2))
+ await sleeper.blockFutureSleeps()
+ networkMonitor.yield(.satisfied())
+ #expect(
+ await waitUntil {
+ store.reviewRun(id: "run-1")?.core.lifecycleMessage == "Network restored; restarting review."
+ })
+ networkMonitor.yield(.satisfied())
+ await settleGate.open()
+ try await backend.waitForRestartPreparedReview(timeout: .seconds(2))
+ try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun))
+
+ await backend.yield(.completed(finalReview: "No issues found."), for: recoveredRun)
+ let read = try await result
+
+ #expect(read.core.lifecycle.status == .succeeded)
+ #expect(read.core.run.turnID == "turn-2")
+ }
+ }
+
+ @Test func networkRecoveryUsesActualStartedTurn() async throws {
+ let initialRun = CodexReviewBackendModel.Review.Run(
+ threadID: "thread-1",
+ turnID: "turn-response",
+ reviewThreadID: "review-thread-1",
+ model: "gpt-5"
+ )
+ let recoveredRun = CodexReviewBackendModel.Review.Run(
+ attemptID: "attempt-recovered",
+ threadID: "thread-1",
+ turnID: "turn-recovered",
+ reviewThreadID: "review-thread-1",
+ model: "gpt-5"
+ )
+ let backend = FakeCodexReviewBackend(nextRun: initialRun)
+ await backend.setNextRecoveredRun(recoveredRun)
+ let networkMonitor = ManualCodexReviewNetworkMonitor()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" }),
+ networkMonitor: networkMonitor,
+ networkRecoveryPolicy: .init(sleep: { _ in })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let result = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
+ )
+
+ await backend.yield(
+ .started(
+ turnID: "turn-actual",
+ reviewThreadID: "review-thread-1",
+ model: "gpt-5"
+ ), for: initialRun)
+ #expect(
+ await waitUntil {
+ store.reviewRun(id: "run-1")?.core.run.turnID == "turn-actual"
+ })
+
+ networkMonitor.yield(.init(status: .unsatisfied))
+ try await backend.waitForPrepareReviewRestart(timeout: .seconds(2))
+ let commandsAfterInterrupt = await backend.recordedCommands()
+ let interruptedRuns = commandsAfterInterrupt.compactMap { command -> CodexReviewBackendModel.Review.Run? in
+ if case .prepareReviewRestart(let run) = command {
+ return run
+ }
+ return nil
+ }
+ #expect(interruptedRuns.last?.turnID == "turn-actual")
+
+ networkMonitor.yield(.satisfied())
+ try await backend.waitForRestartPreparedReview(timeout: .seconds(2))
+ let commandsAfterRecovery = await backend.recordedCommands()
+ let recoveredFromRuns = commandsAfterRecovery.compactMap { command -> CodexReviewBackendModel.Review.Run? in
+ if case .restartPreparedReview(let token, _) = command {
+ return token.interruptedRun
+ }
+ return nil
+ }
+ #expect(recoveredFromRuns.last?.turnID == "turn-actual")
+
+ try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun))
+ await backend.yield(.completed(finalReview: "No issues found."), for: recoveredRun)
+ let read = try await result
+
+ #expect(read.core.lifecycle.status == .succeeded)
+ #expect(read.core.run.turnID == "turn-recovered")
+ }
+ }
+
+ @Test func networkRecoveryIgnoresStaleCompletionAfterRecoveredSubscriptionStarts() async throws {
+ let initialRun = CodexReviewBackendModel.Review.Run(
+ threadID: "thread-1",
+ turnID: "turn-1",
+ reviewThreadID: "review-thread-1",
+ model: "gpt-5"
+ )
+ let recoveredRun = CodexReviewBackendModel.Review.Run(
+ attemptID: "attempt-recovered",
+ threadID: "thread-1",
+ turnID: "turn-2",
+ reviewThreadID: "review-thread-1",
+ model: "gpt-5"
+ )
+ let backend = FakeCodexReviewBackend(nextRun: initialRun)
+ await backend.setNextRecoveredRun(recoveredRun)
+ let networkMonitor = ManualCodexReviewNetworkMonitor()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" }),
+ networkMonitor: networkMonitor,
+ networkRecoveryPolicy: .init(sleep: { _ in })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let result = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
+ )
+
+ networkMonitor.yield(.init(status: .unsatisfied))
+ try await backend.waitForPrepareReviewRestart(timeout: .seconds(2))
+ networkMonitor.yield(.satisfied())
+ try await backend.waitForRestartPreparedReview(timeout: .seconds(2))
+ try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun))
+
+ await backend.yield(.completed(finalReview: "No issues found."), for: initialRun)
+ await backend.yield(.completed(finalReview: "No issues found."), for: recoveredRun)
+
+ let read = try await result
+ #expect(read.core.lifecycle.status == .succeeded)
+ #expect(read.core.run.turnID == "turn-2")
+ }
+ }
+
+ @Test func networkRecoveryIgnoresStaleTerminalQueuedWhileRestarting() async throws {
+ let initialRun = CodexReviewBackendModel.Review.Run(
+ threadID: "thread-1",
+ turnID: "turn-1",
+ reviewThreadID: "review-thread-1",
+ model: "gpt-5"
+ )
+ let recoveredRun = CodexReviewBackendModel.Review.Run(
+ attemptID: "attempt-recovered",
+ threadID: "thread-1",
+ turnID: "turn-2",
+ reviewThreadID: "review-thread-1",
+ model: "gpt-5"
+ )
+ let backend = FakeCodexReviewBackend(nextRun: initialRun)
+ await backend.setNextRecoveredRun(recoveredRun)
+ let recoverGate = AsyncGate()
+ await backend.holdRestartPreparedReview(with: recoverGate)
+ let networkMonitor = ManualCodexReviewNetworkMonitor()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" }),
+ networkMonitor: networkMonitor,
+ networkRecoveryPolicy: .init(sleep: { _ in })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let result = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
+ )
+
+ networkMonitor.yield(.init(status: .unsatisfied))
+ try await backend.waitForPrepareReviewRestart(timeout: .seconds(2))
+ networkMonitor.yield(.satisfied())
+ try await backend.waitForRestartPreparedReview(timeout: .seconds(2))
+ await backend.yield(.cancelled("Network lost"), for: initialRun)
+ await recoverGate.open()
+ try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun))
+
+ await backend.yield(.completed(finalReview: "No issues found."), for: recoveredRun)
+ let read = try await result
+
+ #expect(read.core.lifecycle.status == .succeeded)
+ #expect(read.core.run.turnID == "turn-2")
+ }
+ }
+
+ @Test func networkRecoveryResubscribesWhenInterruptedEventStreamFinished() async throws {
+ let initialRun = CodexReviewBackendModel.Review.Run(
+ threadID: "thread-1",
+ turnID: "turn-1",
+ reviewThreadID: "review-thread-1",
+ model: "gpt-5"
+ )
+ let recoveredRun = CodexReviewBackendModel.Review.Run(
+ attemptID: "attempt-recovered",
+ threadID: "thread-1",
+ turnID: "turn-2",
+ reviewThreadID: "review-thread-1",
+ model: "gpt-5"
+ )
+ let backend = FakeCodexReviewBackend(nextRun: initialRun)
+ await backend.setNextRecoveredRun(recoveredRun)
+ let networkMonitor = ManualCodexReviewNetworkMonitor()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" }),
+ networkMonitor: networkMonitor,
+ networkRecoveryPolicy: .init(sleep: { _ in })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let result = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
+ )
+
+ networkMonitor.yield(.init(status: .unsatisfied))
+ try await backend.waitForPrepareReviewRestart(timeout: .seconds(2))
+ await backend.finishEvents(for: initialRun)
+ networkMonitor.yield(.satisfied())
+ try await backend.waitForRestartPreparedReview(timeout: .seconds(2))
+ try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun))
+
+ await backend.yield(.completed(finalReview: "No issues found."), for: recoveredRun)
+ let read = try await result
+
+ #expect(read.core.lifecycle.status == .succeeded)
+ #expect(read.core.run.turnID == "turn-2")
+ }
+ }
+
+ @Test func cancellationWhileRecoveryRestartIsInFlightStopsRecoveredRun() async throws {
+ let initialRun = CodexReviewBackendModel.Review.Run(
+ threadID: "thread-1",
+ turnID: "turn-1",
+ reviewThreadID: "review-thread-1",
+ model: "gpt-5"
+ )
+ let recoveredRun = CodexReviewBackendModel.Review.Run(
+ attemptID: "attempt-recovered",
+ threadID: "thread-1",
+ turnID: "turn-2",
+ reviewThreadID: "review-thread-1",
+ model: "gpt-5"
+ )
+ let backend = FakeCodexReviewBackend(nextRun: initialRun)
+ await backend.setNextRecoveredRun(recoveredRun)
+ let recoverGate = AsyncGate()
+ await backend.holdRestartPreparedReview(with: recoverGate)
+ let networkMonitor = ManualCodexReviewNetworkMonitor()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" }),
+ networkMonitor: networkMonitor,
+ networkRecoveryPolicy: .init(sleep: { _ in })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let result = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
+ )
+
+ networkMonitor.yield(.init(status: .unsatisfied))
+ try await backend.waitForPrepareReviewRestart(timeout: .seconds(2))
+ networkMonitor.yield(.satisfied())
+ try await backend.waitForRestartPreparedReview(timeout: .seconds(2))
+
+ let cancel = try await store.cancelReview(runID: "run-1", cancellation: .mcpClient(message: "Stop"))
+ #expect(cancel.cancelled)
+ await recoverGate.open()
+
+ let read = try await result
+ #expect(read.core.lifecycle.status == .cancelled)
+ #expect(read.core.run.turnID == "turn-1")
+
+ let commands = await backend.recordedCommands()
+ #expect(
+ commands.contains(
+ .interruptReview(
+ initialRun,
+ .init(message: "Stop")
+ )) == false)
+ #expect(
+ commands.contains(
+ .interruptReview(
+ recoveredRun,
+ .init(message: "Stop")
+ )))
+ #expect(commands.contains(.cleanupReview(recoveredRun)))
+ }
+ }
+
+ @Test func runtimeStopWhileRecoveryRestartIsInFlightDetachesAndStopsRecoveredRun() async throws {
+ let initialRun = CodexReviewBackendModel.Review.Run(
+ threadID: "thread-1",
+ turnID: "turn-1",
+ reviewThreadID: "review-thread-1",
+ model: "gpt-5"
+ )
+ let recoveredRun = CodexReviewBackendModel.Review.Run(
+ attemptID: "attempt-recovered",
+ threadID: "thread-1",
+ turnID: "turn-2",
+ reviewThreadID: "review-thread-1",
+ model: "gpt-5"
+ )
+ let backend = FakeCodexReviewBackend(nextRun: initialRun)
+ await backend.setNextRecoveredRun(recoveredRun)
+ let recoverGate = AsyncGate()
+ await backend.holdRestartPreparedReview(with: recoverGate)
+ let networkMonitor = ManualCodexReviewNetworkMonitor()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" }),
+ networkMonitor: networkMonitor,
+ networkRecoveryPolicy: .init(sleep: { _ in })
+ )
+ let recorder = RuntimeStopCleanupRequestRecorder()
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let result = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
+ )
+
+ networkMonitor.yield(.init(status: .unsatisfied))
+ try await backend.waitForPrepareReviewRestart(timeout: .seconds(2))
+ networkMonitor.yield(.satisfied())
+ try await backend.waitForRestartPreparedReview(timeout: .seconds(2))
+
+ let cleanupTask = Task { @MainActor in
+ await store.cleanupActiveReviewsForRuntimeStop(
+ reason: .system(message: "Review runtime stopped."),
+ workerDrainTimeout: .seconds(2)
+ ) { request in
+ await recorder.record(request)
+ return true
+ }
+ }
+ try #require(
+ await waitUntil {
+ let state = store.runtimeReviewRunState(runID: "run-1")
+ return state.hasActiveWorker == false && state.activeRun == nil
+ })
+ await recoverGate.open()
+ let cleanup = await cleanupTask.value
+ let read = try await result
+ let request = try #require(await recorder.onlyRequest())
+
+ #expect(cleanup.didComplete)
+ #expect(request.recoveryWaitingRuns == [initialRun])
+ #expect(read.core.lifecycle.status == .cancelled)
+ #expect(read.core.lifecycle.cancellation?.message == "Review runtime stopped.")
+ #expect(read.core.run.turnID == "turn-1")
+ let commands = await backend.recordedCommands()
+ #expect(
+ commands.contains(
+ .interruptReview(
+ recoveredRun,
+ .init(message: "Review runtime stopped.")
+ )))
+ #expect(commands.contains(.cleanupReview(recoveredRun)))
+ }
+ }
+
+ @Test func cancellationAfterRecoveryEventStreamFinishesWakesWorker() async throws {
+ let initialRun = CodexReviewBackendModel.Review.Run(
+ threadID: "thread-1",
+ turnID: "turn-1",
+ reviewThreadID: "review-thread-1",
+ model: "gpt-5"
+ )
+ let backend = FakeCodexReviewBackend(nextRun: initialRun)
+ let networkMonitor = ManualCodexReviewNetworkMonitor()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" }),
+ networkMonitor: networkMonitor,
+ networkRecoveryPolicy: .init(sleep: { _ in })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let running = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .baseBranch("main")),
+ waitTimeout: .milliseconds(20)
+ )
+
+ networkMonitor.yield(.init(status: .unsatisfied))
+ try await backend.waitForPrepareReviewRestart(timeout: .seconds(2))
+ _ = try await running
+ await backend.finishEvents(for: initialRun)
+
+ let cancel = try await store.cancelReview(runID: "run-1", cancellation: .mcpClient(message: "Stop"))
+ let cleanedUp = await waitUntil {
+ let runtimeState = store.runtimeReviewRunState(runID: "run-1")
+ return runtimeState.hasActiveWorker == false && runtimeState.activeRun == nil
+ }
+ let read = try store.readReview(runID: "run-1")
+
+ #expect(cancel.cancelled)
+ #expect(cleanedUp)
+ #expect(read.core.lifecycle.status == .cancelled)
+ #expect(read.core.lifecycle.cancellation?.message == "Stop")
+ }
+ }
+
+ @Test func runtimeStopLocalCancellationDetachesWorker() async throws {
+ let run = CodexReviewBackendModel.Review.Run(
+ threadID: "thread-1",
+ turnID: "turn-1",
+ reviewThreadID: "review-thread-1",
+ model: "gpt-5"
+ )
+ let backend = FakeCodexReviewBackend(nextRun: run)
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let running = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .baseBranch("main")),
+ waitTimeout: .milliseconds(20)
+ )
+ _ = try await running
+
+ let locallyCancelledReviewRunIDs = store.cancelActiveReviewsLocallyForRuntimeStop(
+ reason: .system(message: "Review runtime stopped."),
+ cancelWorkers: false
+ )
+ let cancelled = try store.readReview(runID: "run-1")
+
+ #expect(locallyCancelledReviewRunIDs == ["run-1"])
+ #expect(cancelled.core.lifecycle.status == .cancelled)
+ let runtimeStateBeforeDetach = store.runtimeReviewRunState(runID: "run-1")
+ #expect(runtimeStateBeforeDetach.hasActiveWorker)
+ #expect(runtimeStateBeforeDetach.activeRun == run)
+
+ store.cancelAndDetachReviewWorkersForRuntimeStop(runIDs: locallyCancelledReviewRunIDs)
+
+ let runtimeStateAfterDetach = store.runtimeReviewRunState(runID: "run-1")
+ #expect(runtimeStateAfterDetach.hasActiveWorker == false)
+ #expect(runtimeStateAfterDetach.activeRun == nil)
+ }
+ }
+
+ @Test func stopInterruptsActiveReviewBeforeMarkingRunStopped() async throws {
+ let run = CodexReviewBackendModel.Review.Run(
+ threadID: "thread-1",
+ turnID: "turn-1",
+ reviewThreadID: "review-thread-1",
+ model: "gpt-5"
+ )
+ let backend = FakeCodexReviewBackend(nextRun: run)
+ let interruptGate = AsyncGate()
+ await backend.holdInterruptReview(with: interruptGate)
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ await store.start()
+ async let running = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .baseBranch("main")),
+ waitTimeout: .milliseconds(20)
+ )
+ _ = try await running
+
+ let stopTask = Task { @MainActor in
+ await store.stop()
+ }
+ try await backend.waitForInterruptReview(timeout: .seconds(2))
+ let inFlight = try store.readReview(runID: "run-1")
+
+ #expect(inFlight.core.lifecycle.status == .running)
+ await interruptGate.open()
+ await stopTask.value
+
+ let stopped = try store.readReview(runID: "run-1")
+ let commands = await backend.recordedCommands()
+ #expect(commands.contains(.interruptReview(run, .init(message: "Review runtime stopped."))))
+ #expect(stopped.core.lifecycle.status == .cancelled)
+ let runtimeState = store.runtimeReviewRunState(runID: "run-1")
+ #expect(runtimeState.activeRun == nil)
+ #expect(runtimeState.hasActiveWorker == false)
+ }
+ }
+
+ @Test func runtimeStopDetachesNetworkRecoveryWaitingWorker() async throws {
+ let run = CodexReviewBackendModel.Review.Run(
+ threadID: "thread-1",
+ turnID: "turn-1",
+ reviewThreadID: "review-thread-1",
+ model: "gpt-5"
+ )
+ let backend = FakeCodexReviewBackend(nextRun: run)
+ let networkMonitor = ManualCodexReviewNetworkMonitor()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" }),
+ networkMonitor: networkMonitor,
+ networkRecoveryPolicy: .init(sleep: { _ in })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let running = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .baseBranch("main")),
+ waitTimeout: .milliseconds(20)
+ )
+
+ networkMonitor.yield(.init(status: .unsatisfied))
+ try await backend.waitForPrepareReviewRestart(timeout: .seconds(2))
+ _ = try await running
+
+ let locallyCancelledReviewRunIDs = store.cancelActiveReviewsLocallyForRuntimeStop(
+ reason: .system(message: "Review runtime stopped."),
+ cancelWorkers: false
+ )
+ store.cancelAndDetachReviewWorkersForRuntimeStop(runIDs: locallyCancelledReviewRunIDs)
+
+ let runtimeState = store.runtimeReviewRunState(runID: "run-1")
+ #expect(runtimeState.hasActiveWorker == false)
+ #expect(runtimeState.activeRun == nil)
+ #expect(runtimeState.isWaitingForNetworkRecovery == false)
+ }
+ }
+
+ @Test func runtimeStopCleanupHandsRecoveryWaitingRunsToBackendCleanup() async throws {
+ let run = CodexReviewBackendModel.Review.Run(
+ threadID: "thread-1",
+ turnID: "turn-1",
+ reviewThreadID: "review-thread-1",
+ model: "gpt-5"
+ )
+ let backend = FakeCodexReviewBackend(nextRun: run)
+ let networkMonitor = ManualCodexReviewNetworkMonitor()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" }),
+ networkMonitor: networkMonitor,
+ networkRecoveryPolicy: .init(sleep: { _ in })
+ )
+ let recorder = RuntimeStopCleanupRequestRecorder()
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let running = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .baseBranch("main")),
+ waitTimeout: .milliseconds(20)
+ )
+
+ networkMonitor.yield(.init(status: .unsatisfied))
+ try await backend.waitForPrepareReviewRestart(timeout: .seconds(2))
+ _ = try await running
+
+ let result = await store.cleanupActiveReviewsForRuntimeStop(
+ reason: .system(message: "Review runtime stopped."),
+ workerDrainTimeout: .seconds(2)
+ ) { request in
+ await recorder.record(request)
+ return true
+ }
+ let request = try #require(await recorder.onlyRequest())
+ let read = try store.readReview(runID: "run-1")
+
+ #expect(result.didComplete)
+ #expect(request.reason.message == "Review runtime stopped.")
+ #expect(request.recoveryWaitingRuns == [run])
+ #expect(read.core.lifecycle.status == .cancelled)
+ let runtimeState = store.runtimeReviewRunState(runID: "run-1")
+ #expect(runtimeState.hasActiveWorker == false)
+ #expect(runtimeState.activeRun == nil)
+ #expect(runtimeState.isWaitingForNetworkRecovery == false)
+ }
+ }
+
+ @Test func runtimeStopCanDrainDetachedWorkerCleanup() async throws {
+ let run = CodexReviewBackendModel.Review.Run(
+ threadID: "thread-1",
+ turnID: "turn-1",
+ reviewThreadID: "review-thread-1",
+ model: "gpt-5"
+ )
+ let backend = FakeCodexReviewBackend(nextRun: run)
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let running = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .baseBranch("main")),
+ waitTimeout: .milliseconds(20)
+ )
+ _ = try await running
+
+ let locallyCancelledReviewRunIDs = store.cancelActiveReviewsLocallyForRuntimeStop(
+ reason: .system(message: "Review runtime stopped."),
+ cancelWorkers: false
+ )
+ store.cancelAndDetachReviewWorkersForRuntimeStop(runIDs: locallyCancelledReviewRunIDs)
+
+ #expect(await store.drainRuntimeStopDetachedReviewWorkers(timeout: .seconds(2)))
+ #expect(store.runtimeReviewRunState(runID: "run-1").hasDetachedWorker == false)
+ #expect(await backend.recordedCommands().contains(.cleanupReview(run)))
+ }
+ }
+
+ @Test func runtimeStopDetachLetsStartReviewReturnWhenBackendStartIsStuck() async throws {
+ let backend = FakeCodexReviewBackend()
+ let startReviewGate = AsyncGate()
+ await backend.holdStartReview(with: startReviewGate)
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ let running = Task { @MainActor in
+ try await store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
+ )
+ }
+ try await backend.waitForStartReview(timeout: .seconds(2))
+
+ let locallyCancelledReviewRunIDs = store.cancelActiveReviewsLocallyForRuntimeStop(
+ reason: .system(message: "Review runtime stopped."),
+ cancelWorkers: false
+ )
+ store.cancelAndDetachReviewWorkersForRuntimeStop(runIDs: locallyCancelledReviewRunIDs)
+ let resultBeforeStartReviewUnblocked = try await waitForTaskValue(running, timeout: .seconds(1))
+ await startReviewGate.open()
+ let result = try #require(resultBeforeStartReviewUnblocked)
+
+ #expect(locallyCancelledReviewRunIDs == ["run-1"])
+ #expect(result.core.lifecycle.status == .cancelled)
+ let runtimeState = store.runtimeReviewRunState(runID: "run-1")
+ #expect(runtimeState.hasActiveWorker == false)
+ #expect(runtimeState.activeRun == nil)
+ }
+ }
+
+ @Test func cancellationDuringNetworkRecoveryStopsWhenEventStreamFinishes() async throws {
+ let initialRun = CodexReviewBackendModel.Review.Run(
+ threadID: "thread-1",
+ turnID: "turn-1",
+ reviewThreadID: "review-thread-1",
+ model: "gpt-5"
+ )
+ let backend = FakeCodexReviewBackend(nextRun: initialRun)
+ let networkMonitor = ManualCodexReviewNetworkMonitor()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" }),
+ networkMonitor: networkMonitor,
+ networkRecoveryPolicy: .init(sleep: { _ in })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let result = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
+ )
+
+ networkMonitor.yield(.init(status: .unsatisfied))
+ try await backend.waitForPrepareReviewRestart(timeout: .seconds(2))
+ _ = try await store.cancelReview(runID: "run-1", cancellation: .mcpClient(message: "Stop"))
+ await backend.finishEvents(for: initialRun)
+
+ let read = try await result
+ #expect(read.core.lifecycle.status == .cancelled)
+ #expect(read.core.lifecycle.cancellation?.message == "Stop")
+ }
+ }
+
+ @Test func userCancellationWinsOverPendingNetworkRecovery() async throws {
+ let backend = FakeCodexReviewBackend()
+ let networkMonitor = ManualCodexReviewNetworkMonitor()
+ let debounceGate = AsyncGate()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" }),
+ networkMonitor: networkMonitor,
+ networkRecoveryPolicy: .init(sleep: { _ in await debounceGate.wait() })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let result = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
+ )
+ try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil)
+
+ networkMonitor.yield(.init(status: .unsatisfied))
+ _ = try await store.cancelReview(runID: "run-1", cancellation: .mcpClient(message: "Stop"))
+ await debounceGate.open()
+ await backend.yield(.cancelled("Stop"))
+ let read = try await result
+
+ #expect(read.core.lifecycle.status == .cancelled)
+ let commands = await backend.recordedCommands()
+ #expect(
+ commands.contains { command in
+ if case .prepareReviewRestart = command {
+ true
+ } else {
+ false
+ }
+ } == false)
+ #expect(
+ commands.contains { command in
+ if case .restartPreparedReview = command {
+ true
+ } else {
+ false
+ }
+ } == false)
+ }
+ }
+
+ @Test func sessionScopedCancelRejectsRunFromDifferentSession() async throws {
+ let backend = FakeCodexReviewBackend()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let result = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
+ )
+
+ await #expect(throws: (any Error).self) {
+ try await store.cancelReview(
+ runID: "run-1",
+ sessionID: "session-2",
+ cancellation: .mcpClient(message: "Stop")
+ )
+ }
+ #expect(try store.readReview(runID: "run-1").cancellable)
+
+ await backend.yield(.completed(finalReview: "No issues found."))
+ _ = try await result
+
+ let commands = await backend.recordedCommands()
+ #expect(
+ commands.contains {
+ if case .interruptReview = $0 {
+ return true
+ }
+ return false
+ } == false)
+ }
+ }
+
+ @Test func cancelledReviewStaysCancelledWhenStreamClosesWithError() async throws {
+ let backend = FakeCodexReviewBackend()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let result = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
+ )
+ try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil)
+ _ = try await store.cancelReview(
+ runID: "run-1",
+ cancellation: .mcpClient(message: "Stop")
+ )
+ await backend.finishEvents(throwing: StreamClosedError())
+ let read = try await result
+
+ #expect(read.core.lifecycle.status == .cancelled)
+ #expect(read.core.lifecycleMessage == "Stop")
+ }
+ }
+
+ @Test func failedReviewDoesNotRequireReviewText() async throws {
+ let backend = FakeCodexReviewBackend()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let result = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
+ )
+ try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil)
+ await backend.finishEvents(throwing: StreamClosedError())
+ let read = try await result
+
+ #expect(read.core.lifecycle.status == .failed)
+ }
+ }
+
+ @Test func pendingNetworkOutageDefersStreamFailureUntilRecovery() async throws {
+ let initialRun = CodexReviewBackendModel.Review.Run(
+ threadID: "thread-1",
+ turnID: "turn-1",
+ reviewThreadID: "review-thread-1",
+ model: "gpt-5"
+ )
+ let recoveredRun = CodexReviewBackendModel.Review.Run(
+ attemptID: "attempt-recovered",
+ threadID: "thread-1",
+ turnID: "turn-2",
+ reviewThreadID: "review-thread-1",
+ model: "gpt-5"
+ )
+ let backend = FakeCodexReviewBackend(nextRun: initialRun)
+ await backend.setNextRecoveredRun(recoveredRun)
+ let networkMonitor = ManualCodexReviewNetworkMonitor()
+ let outageSleepStarted = AsyncGate()
+ let debounceGate = AsyncGate()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" }),
+ networkMonitor: networkMonitor,
+ networkRecoveryPolicy: .init(
+ outageDebounce: .seconds(10),
+ recoverySettle: .seconds(1),
+ sleep: { _ in
+ await outageSleepStarted.open()
+ await debounceGate.wait()
+ }
+ )
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let result = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
+ )
+ try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil)
+
+ networkMonitor.yield(.init(status: .unsatisfied))
+ await outageSleepStarted.wait()
+ await backend.finishEvents(throwing: StreamClosedError(), for: initialRun)
+
+ let failedBeforeOutageConfirmed =
+ await StoreSnapshotProbe(store: store)
+ .waitUntilRunStatus(.failed, runID: "run-1", timeout: .milliseconds(100)) != nil
+ #expect(failedBeforeOutageConfirmed == false)
+
+ await debounceGate.open()
+ try await backend.waitForPrepareReviewRestart(timeout: .seconds(2))
+ networkMonitor.yield(.satisfied())
+ try await backend.waitForRestartPreparedReview(timeout: .seconds(2))
+ try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun))
+
+ await backend.yield(.completed(finalReview: "No issues found."), for: recoveredRun)
+ let read = try await result
+
+ #expect(read.core.lifecycle.status == .succeeded)
+ }
+ }
+
+ @Test func reviewStartCancellationInterruptsBackendRun() async throws {
+ let backend = FakeCodexReviewBackend()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let result = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
+ )
+ try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil)
+ await backend.finishEvents(throwing: CancellationError())
+ let read = try await result
+
+ #expect(read.core.lifecycle.status == .cancelled)
+ let commands = await backend.recordedCommands()
+ #expect(
+ commands.contains(
+ .interruptReview(
+ .init(threadID: "thread-1", turnID: "turn-1", reviewThreadID: "review-thread-1"),
+ .init(message: "Cancellation requested.")
+ )))
+ }
+ }
+
+ @Test func reviewStartTaskCancellationInterruptsBackendRun() async throws {
+ let backend = FakeCodexReviewBackend()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ let task = Task { @MainActor in
+ try await store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
+ )
+ }
+ task.cancel()
+ let read = try await task.value
+
+ #expect(read.core.lifecycle.status == .cancelled)
+ let commands = await backend.recordedCommands()
+ #expect(
+ commands.contains(
+ .interruptReview(
+ .init(threadID: "thread-1", turnID: "turn-1", reviewThreadID: "review-thread-1"),
+ .init(message: "Cancellation requested.")
+ )))
+ }
+ }
+
+ @Test func failedInterruptClearsCancellationRequestState() async throws {
+ let backend = FakeCodexReviewBackend()
+ await backend.failInterrupts(message: "Interrupt failed")
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let result = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
+ )
+ try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil)
+ await #expect(throws: FakeCodexReviewBackendError.self) {
+ try await store.cancelReview(
+ runID: "run-1",
+ cancellation: .mcpClient(message: "Stop")
+ )
+ }
+ let readAfterFailure = try store.readReview(runID: "run-1")
+
+ #expect(readAfterFailure.cancellable)
+ #expect(readAfterFailure.core.lifecycle.cancellation == nil)
+ #expect(readAfterFailure.core.lifecycleMessage == "Failed to cancel review: Interrupt failed")
+
+ await backend.yield(.completed(finalReview: "No issues found."))
+ _ = try await result
+ }
+ }
+
+ @Test func cancelledReviewIgnoresBufferedTerminalEvents() async throws {
+ let backend = FakeCodexReviewBackend()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let result = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
+ )
+ try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil)
+ _ = try await store.cancelReview(
+ runID: "run-1",
+ cancellation: .mcpClient(message: "Stop")
+ )
+ await backend.yield(.completed(finalReview: "No issues found."))
+ let read = try await result
+
+ #expect(read.core.lifecycle.status == .cancelled)
+ #expect(read.core.lifecycleMessage == "Stop")
+ }
+ }
+
+ @Test func terminalEventDuringPendingCancellationKeepsCancelledState() async throws {
+ let backend = FakeCodexReviewBackend()
+ let interruptGate = AsyncGate()
+ await backend.holdInterruptReview(with: interruptGate)
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let result = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
+ )
+ async let cancel = store.cancelReview(runID: "run-1", cancellation: .mcpClient(message: "Stop"))
+ try await backend.waitForInterruptReview(timeout: .seconds(2))
+ await backend.yield(.completed(finalReview: "No issues found."))
+ await interruptGate.open()
+ _ = try await cancel
+ let read = try await result
+
+ #expect(read.core.lifecycle.status == .cancelled)
+ #expect(read.core.lifecycleMessage == "Stop")
+ }
+ }
+
+ @Test func cancelDuringReviewStartupInterruptsAfterRunBecomesAvailable() async throws {
+ let backend = FakeCodexReviewBackend()
+ let gate = AsyncGate()
+ await backend.holdStartReview(with: gate)
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ async let result = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
+ )
+ try await backend.waitForStartReview(timeout: .seconds(2))
+ let cancel = try await store.cancelReview(runID: "run-1", cancellation: .mcpClient(message: "Stop"))
+ let cancelledDuringStartup = try #require(store.reviewRuns.first)
+ #expect(cancel.core.lifecycle.status == .cancelled)
+ #expect(cancelledDuringStartup.core.lifecycle.status == .cancelled)
+ await gate.open()
+ let read = try await result
+
+ #expect(cancel.cancelled)
+ #expect(read.core.lifecycle.status == .cancelled)
+ let commands = await backend.recordedCommands()
+ #expect(
+ commands.contains(
+ .interruptReview(
+ .init(threadID: "thread-1", turnID: "turn-1", reviewThreadID: "review-thread-1"),
+ .init(message: "Stop")
+ )))
+ #expect(
+ commands.contains(
+ .cleanupReview(
+ .init(
+ threadID: "thread-1",
+ turnID: "turn-1",
+ reviewThreadID: "review-thread-1"
+ ))))
+ }
+ }
+
+ @Test func closedSessionRejectsNewReviews() async throws {
+ let backend = FakeCodexReviewBackend()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
+ )
+ await withStoreCommandTestCleanup(backend: backend, store: store) {
+ await store.closeSession("session-1")
+
+ await #expect(throws: (any Error).self) {
+ try await store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
+ )
+ }
+ }
+ }
+
+ @Test func closeActiveReviewSessionsCancelsRunsWithoutClosingMCPServerSession() async throws {
+ let backend = FakeCodexReviewBackend()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
+ idGenerator: .init(next: { "run-1" })
+ )
+ try await withStoreCommandTestCleanup(backend: backend, store: store) {
+ let running = ReviewRunRecord.makeForTesting(
+ id: "running-run",
+ sessionID: "session-1",
+ cwd: "/tmp/project",
+ targetSummary: "Running",
+ status: .running,
+ summary: "Running"
+ )
+ store.loadForTesting(
+ serverState: .running,
+ reviewRuns: [running]
+ )
+
+ await store.closeActiveReviewSessions(reason: .system(message: "Account switched."))
+
+ #expect(running.core.lifecycle.status == .cancelled)
+ async let result = store.startReview(
+ sessionID: "session-1",
+ request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
+ )
+ await backend.yield(.completed(finalReview: "No issues found."))
+ let read = try await result
+
+ #expect(read.runID == "run-1")
+ #expect(read.core.lifecycle.status == .succeeded)
+ }
+ }
+
+ @Test func authAndSettingsUseSingleBackendContract() async throws {
+ let backend = FakeCodexReviewBackend(settings: .init(model: "gpt-5"))
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
+ )
+ await withStoreCommandTestCleanup(backend: backend, store: store) {
+ await store.refreshSettings()
+
+ #expect(store.settings.effectiveModel == "gpt-5")
+ }
+ }
+
+ @Test func initialActiveAccountKeySelectsPersistedAccount() {
+ let active = CodexReviewAccount(email: "active@example.com")
+ let inactive = CodexReviewAccount(email: "inactive@example.com")
+ let backend = FakeCodexReviewBackend()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(
+ reviewBackend: backend,
+ seed: .init(
+ initialAccounts: [inactive, active],
+ initialActiveAccountKey: active.accountKey
+ )
+ )
+ )
+
+ #expect(
+ store.auth.persistedAccounts.map(\.accountKey) == [
+ inactive.accountKey,
+ active.accountKey,
+ ])
+ #expect(store.auth.persistedActiveAccountKey == active.accountKey)
+ #expect(store.auth.selectedAccount?.accountKey == active.accountKey)
+ }
+
+ @Test func switchActionsAreUnavailableForSelectedAccount() async throws {
+ let selectedAccount = CodexReviewAccount(email: "selected@example.com", planType: "pro")
+ let otherAccount = CodexReviewAccount(email: "other@example.com", planType: "plus")
+ let backend = SwitchRecordingBackend()
+ let store = CodexReviewStore.makeTestingStore(backend: backend)
+ store.loadForTesting(
+ serverState: .running,
+ account: selectedAccount,
+ persistedAccounts: [selectedAccount, otherAccount]
+ )
+ let displayedSelectedAccount = try #require(store.auth.selectedAccount)
+ let displayedOtherAccount = try #require(
+ store.auth.persistedAccounts.first { $0.accountKey == otherAccount.accountKey }
+ )
+
+ #expect(store.switchActionIsDisabled(for: displayedSelectedAccount))
+ #expect(store.switchActionRequiresRunningReviewRunsConfirmation(for: displayedSelectedAccount) == false)
+ #expect(store.switchActionIsDisabled(for: displayedOtherAccount) == false)
+ #expect(store.switchActionRequiresRunningReviewRunsConfirmation(for: displayedOtherAccount))
+
+ store.requestSwitchAccountFromUserAction(displayedSelectedAccount)
+ await Task.yield()
+ #expect(backend.switchRequests.isEmpty)
+
+ try await store.switchAccount(displayedSelectedAccount)
+ #expect(backend.switchRequests.isEmpty)
+
+ try await store.switchAccount(displayedOtherAccount)
+ #expect(backend.switchRequests == [displayedOtherAccount.accountKey])
+ }
+
+ @Test func fakeBackendPreservesSettingsCatalogWhenApplyingOverrides() async throws {
+ let model = CodexReviewSettings.ModelCatalogItem(
+ id: "gpt-5.5",
+ model: "gpt-5.5",
+ displayName: "GPT-5.5",
+ hidden: false,
+ supportedReasoningEfforts: [
+ .init(reasoningEffort: .medium, description: "Balanced")
+ ],
+ defaultReasoningEffort: .medium,
+ supportedServiceTiers: [.fast],
+ isDefault: true
+ )
+ let backend = FakeCodexReviewBackend(
+ settings: .init(
+ fallbackModel: "gpt-5.5",
+ models: [model]
+ ))
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
+ )
+ await withStoreCommandTestCleanup(backend: backend, store: store) {
+ await store.refreshSettings()
+ await store.updateSettingsReasoningEffort(.medium)
+
+ #expect(store.settings.effectiveModel == "gpt-5.5")
+ #expect(store.settings.models == [model])
+ }
+ }
+
+ @Test func primaryAuthenticationActionIsAvailableWhenRuntimeCanRecoverOrStartLogin() {
+ let backend = FakeCodexReviewBackend()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
+ )
+
+ store.loadForTesting(serverState: .stopped, authPhase: .signedOut)
+ #expect(store.canPerformPrimaryAuthenticationAction)
+
+ store.loadForTesting(serverState: .failed("Runtime failed."), authPhase: .signedOut)
+ #expect(store.canPerformPrimaryAuthenticationAction)
+
+ store.loadForTesting(serverState: .starting, authPhase: .signedOut)
+ #expect(store.canPerformPrimaryAuthenticationAction == false)
+
+ store.loadForTesting(serverState: .running, authPhase: .signedOut)
+ #expect(store.canPerformPrimaryAuthenticationAction)
+
+ store.auth.updatePhase(.signingIn(.init(title: "Sign in", detail: "Open browser.")))
+ store.transitionToFailed("Runtime failed.")
+ #expect(store.canPerformPrimaryAuthenticationAction)
+ }
+
+ @Test func primaryAuthenticationActionRestartsRecoverableRuntimeBeforeLogin() async {
+ let backend = FakeCodexReviewBackend()
+ let store = CodexReviewStore.makeTestingStore(
+ backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
+ )
+ await withStoreCommandTestCleanup(backend: backend, store: store) {
+ store.loadForTesting(serverState: .failed("Runtime failed."), authPhase: .signedOut)
+
+ await store.performPrimaryAuthenticationAction()
+
+ #expect(store.serverState == .running)
+ #expect(store.auth.isAuthenticating)
+ let commands = await backend.recordedCommands()
+ #expect(
+ commands.contains { command in
+ if case .startLogin = command {
+ return true
+ }
+ return false
+ })
+ }
+ }
+}
+
+@MainActor
+private final class SwitchRecordingBackend: PreviewCodexReviewStoreBackend {
+ private(set) var switchRequests: [String] = []
+
+ override func switchAccount(
+ auth _: CodexReviewAuthModel,
+ accountKey: String
+ ) async throws {
+ switchRequests.append(accountKey)
+ }
+
+ override func requiresCurrentSessionRecovery(
+ auth _: CodexReviewAuthModel,
+ accountKey _: String
+ ) -> Bool {
+ true
+ }
+}
+
+@MainActor
+private func waitUntil(
+ timeout: Duration = .seconds(2),
+ condition: () async -> Bool
+) async -> Bool {
+ let clock = ContinuousClock()
+ let deadline = clock.now + timeout
+ while await condition() == false {
+ if clock.now >= deadline {
+ return false
+ }
+ try? await Task.sleep(for: .milliseconds(10))
+ }
+ return true
+}
+
+@MainActor
+private func waitForRunAttemptActivation(
+ store: CodexReviewStore,
+ run: CodexReviewBackendModel.Review.Run,
+ timeout: Duration = .seconds(2)
+) async -> Bool {
+ await StoreSnapshotProbe(store: store)
+ .waitUntilRunAttempt(run.attemptID, timeout: timeout) != nil
+}
+
+private func waitForTaskValue(
+ _ task: Task,
+ timeout: Duration
+) async throws -> T? {
+ try await withThrowingTaskGroup(of: T?.self) { group in
+ group.addTask {
+ try await task.value
+ }
+ group.addTask {
+ try await Task.sleep(for: timeout)
+ return nil
+ }
+ let result = try await group.next() ?? nil
+ group.cancelAll()
+ return result
+ }
+}
+
+@MainActor
+private func withStoreCommandTestCleanup(
+ backend: FakeCodexReviewBackend,
+ store: CodexReviewStore,
+ operation: () async throws -> Void
+) async rethrows {
+ do {
+ try await operation()
+ } catch {
+ await cleanupStoreCommandTest(backend: backend, store: store)
+ throw error
+ }
+ await cleanupStoreCommandTest(backend: backend, store: store)
+}
+
+@MainActor
+private func cleanupStoreCommandTest(
+ backend: FakeCodexReviewBackend,
+ store: CodexReviewStore
+) async {
+ await backend.finishEventMailboxes()
+ await store.cancelAndDrainReviewWorkersForTesting()
+ await backend.finishEventMailboxes()
+}
+
+private struct StreamClosedError: Error {}
+
+private actor RuntimeStopCleanupRequestRecorder {
+ private var requests: [CodexReviewRuntimeStopReviewCleanupRequest] = []
+
+ func record(_ request: CodexReviewRuntimeStopReviewCleanupRequest) {
+ requests.append(request)
+ }
+
+ func onlyRequest() -> CodexReviewRuntimeStopReviewCleanupRequest? {
+ requests.count == 1 ? requests[0] : nil
+ }
+}
+
+private actor ControlledTestSleeper {
+ private let gate: AsyncGate
+ private var shouldBlock = false
+
+ init(gate: AsyncGate) {
+ self.gate = gate
+ }
+
+ func blockFutureSleeps() {
+ shouldBlock = true
+ }
+
+ func sleep() async {
+ if shouldBlock {
+ await gate.wait()
+ }
+ }
+}
+
+private final class MutableTestClock: @unchecked Sendable {
+ var current: Date
+
+ init(_ current: Date) {
+ self.current = current
+ }
+
+ func now() -> Date {
+ current
+ }
+}
diff --git a/Tests/CodexReviewTests/CodexReviewStoreRateLimitAutoRefreshTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift
similarity index 90%
rename from Tests/CodexReviewTests/CodexReviewStoreRateLimitAutoRefreshTests.swift
rename to Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift
index 41042100..16887ed3 100644
--- a/Tests/CodexReviewTests/CodexReviewStoreRateLimitAutoRefreshTests.swift
+++ b/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift
@@ -1,6 +1,6 @@
import Foundation
import Testing
-@_spi(Testing) @testable import CodexReview
+@_spi(Testing) @testable import CodexReviewKit
import CodexReviewTesting
@Suite("Store rate limit auto refresh")
@@ -11,7 +11,7 @@ struct CodexReviewStoreRateLimitAutoRefreshTests {
@Test func selectedRunningAccountRefreshesAfterOneMinute() {
let account = makeAccount(lastFetchAt: now)
- let targets = targets(accounts: [account], selectedAccount: account, hasRunningJobs: true)
+ let targets = targets(accounts: [account], selectedAccount: account, hasRunningReviewRuns: true)
#expect(targets == [
.init(accountKey: account.accountKey, kind: .selectedRunningInterval, dueAt: now.addingTimeInterval(60)),
@@ -21,7 +21,7 @@ struct CodexReviewStoreRateLimitAutoRefreshTests {
@Test func selectedIdleAccountRefreshesAfterFifteenMinutes() {
let account = makeAccount(lastFetchAt: now)
- let targets = targets(accounts: [account], selectedAccount: account, hasRunningJobs: false)
+ let targets = targets(accounts: [account], selectedAccount: account, hasRunningReviewRuns: false)
#expect(targets == [
.init(accountKey: account.accountKey, kind: .selectedIdleInterval, dueAt: now.addingTimeInterval(15 * 60)),
@@ -35,7 +35,7 @@ struct CodexReviewStoreRateLimitAutoRefreshTests {
let targets = targets(
accounts: [selectedAccount, otherAccount],
selectedAccount: selectedAccount,
- hasRunningJobs: false
+ hasRunningReviewRuns: false
)
#expect(targets == [
@@ -63,7 +63,7 @@ struct CodexReviewStoreRateLimitAutoRefreshTests {
let targets = targets(
accounts: [selectedAccount, otherAccount],
selectedAccount: selectedAccount,
- hasRunningJobs: true
+ hasRunningReviewRuns: true
)
#expect(targets == [
@@ -75,14 +75,14 @@ struct CodexReviewStoreRateLimitAutoRefreshTests {
])
}
- @Test func runningJobsOnlyAccelerateSelectedAccount() {
+ @Test func runningRunsOnlyAccelerateSelectedAccount() {
let selectedAccount = makeAccount(email: "selected@example.com", lastFetchAt: now)
let otherAccount = makeAccount(email: "other@example.com", lastFetchAt: now)
let targets = targets(
accounts: [selectedAccount, otherAccount],
selectedAccount: selectedAccount,
- hasRunningJobs: true
+ hasRunningReviewRuns: true
)
#expect(targets == [
@@ -108,7 +108,7 @@ struct CodexReviewStoreRateLimitAutoRefreshTests {
]
)
- let targets = targets(accounts: [account], selectedAccount: account, hasRunningJobs: false)
+ let targets = targets(accounts: [account], selectedAccount: account, hasRunningReviewRuns: false)
#expect(targets == [
.init(accountKey: account.accountKey, kind: .resetWindow, dueAt: resetAt.addingTimeInterval(60)),
@@ -125,7 +125,7 @@ struct CodexReviewStoreRateLimitAutoRefreshTests {
]
)
- let targets = targets(accounts: [account], selectedAccount: account, hasRunningJobs: false)
+ let targets = targets(accounts: [account], selectedAccount: account, hasRunningReviewRuns: false)
#expect(targets == [
.init(
@@ -139,7 +139,7 @@ struct CodexReviewStoreRateLimitAutoRefreshTests {
@Test func nilLastFetchUsesNowUnlessResetIsUnconsumed() {
let account = makeAccount(lastFetchAt: nil)
- let targets = targets(accounts: [account], selectedAccount: account, hasRunningJobs: false)
+ let targets = targets(accounts: [account], selectedAccount: account, hasRunningReviewRuns: false)
#expect(targets == [
.init(accountKey: account.accountKey, kind: .selectedIdleInterval, dueAt: now.addingTimeInterval(15 * 60)),
@@ -155,16 +155,16 @@ struct CodexReviewStoreRateLimitAutoRefreshTests {
]
)
- let targets = targets(accounts: [account], selectedAccount: account, hasRunningJobs: false)
+ let targets = targets(accounts: [account], selectedAccount: account, hasRunningReviewRuns: false)
#expect(targets == [
.init(accountKey: account.accountKey, kind: .resetWindow, dueAt: resetAt.addingTimeInterval(60)),
])
}
- @Test func storeTargetsFollowRunningJobState() {
+ @Test func storeTargetsFollowRunningRunState() {
let account = makeAccount(lastFetchAt: now)
- let runningJob = CodexReviewJob.makeForTesting(
+ let runningRun = ReviewRunRecord.makeForTesting(
targetSummary: "Review changes",
status: .running,
startedAt: now,
@@ -172,17 +172,17 @@ struct CodexReviewStoreRateLimitAutoRefreshTests {
)
let store = CodexReviewStore.makePreviewStore()
- loadStore(store, account: account, jobs: [])
+ loadStore(store, account: account, reviewRuns: [])
#expect(store.accountRateLimitAutoRefreshTargets(now: now) == [
.init(accountKey: account.accountKey, kind: .selectedIdleInterval, dueAt: now.addingTimeInterval(15 * 60)),
])
- loadStore(store, account: account, jobs: [runningJob])
+ loadStore(store, account: account, reviewRuns: [runningRun])
#expect(store.accountRateLimitAutoRefreshTargets(now: now) == [
.init(accountKey: account.accountKey, kind: .selectedRunningInterval, dueAt: now.addingTimeInterval(60)),
])
- loadStore(store, account: account, jobs: [])
+ loadStore(store, account: account, reviewRuns: [])
#expect(store.accountRateLimitAutoRefreshTargets(now: now) == [
.init(accountKey: account.accountKey, kind: .selectedIdleInterval, dueAt: now.addingTimeInterval(15 * 60)),
])
@@ -196,8 +196,7 @@ struct CodexReviewStoreRateLimitAutoRefreshTests {
serverState: .running,
authPhase: .signedOut,
account: account,
- persistedAccounts: [account],
- workspaces: []
+ persistedAccounts: [account]
)
store.refreshDueAccountRateLimits(now: now)
@@ -225,8 +224,7 @@ struct CodexReviewStoreRateLimitAutoRefreshTests {
serverState: .running,
authPhase: .signedOut,
account: account,
- persistedAccounts: [account],
- workspaces: []
+ persistedAccounts: [account]
)
store.refreshDueAccountRateLimits(now: now)
@@ -253,8 +251,7 @@ struct CodexReviewStoreRateLimitAutoRefreshTests {
serverState: .running,
authPhase: .signedOut,
account: account,
- persistedAccounts: [account],
- workspaces: []
+ persistedAccounts: [account]
)
store.refreshDueAccountRateLimits(now: now)
@@ -274,8 +271,8 @@ struct CodexReviewStoreRateLimitAutoRefreshTests {
lastFetchAt: Date?,
capabilities: CodexReviewBackendModel.Account.Capabilities = .supportsCodexRateLimits,
rateLimits: [(windowDurationMinutes: Int, usedPercent: Int, resetsAt: Date?)] = []
- ) -> CodexAccount {
- let account = CodexAccount(
+ ) -> CodexReviewAccount {
+ let account = CodexReviewAccount(
accountKey: accountKey,
email: email,
planType: "pro",
@@ -287,15 +284,15 @@ struct CodexReviewStoreRateLimitAutoRefreshTests {
}
private func targets(
- accounts: [CodexAccount],
- selectedAccount: CodexAccount?,
- hasRunningJobs: Bool,
+ accounts: [CodexReviewAccount],
+ selectedAccount: CodexReviewAccount?,
+ hasRunningReviewRuns: Bool,
serverState: CodexReviewServerState = .running
) -> [CodexReviewStoreRateLimitAutoRefreshTarget] {
CodexReviewStoreRateLimitAutoRefreshDriver.targets(
accounts: accounts,
selectedAccountKey: selectedAccount?.accountKey,
- hasRunningJobs: hasRunningJobs,
+ hasRunningReviewRuns: hasRunningReviewRuns,
serverState: serverState,
now: now
)
@@ -303,16 +300,15 @@ struct CodexReviewStoreRateLimitAutoRefreshTests {
private func loadStore(
_ store: CodexReviewStore,
- account: CodexAccount,
- jobs: [CodexReviewJob]
+ account: CodexReviewAccount,
+ reviewRuns: [ReviewRunRecord]
) {
store.loadForTesting(
serverState: .running,
authPhase: .signedOut,
account: account,
persistedAccounts: [account],
- workspaces: [CodexReviewWorkspace(cwd: "/tmp/repo")],
- jobs: jobs
+ reviewRuns: reviewRuns
)
}
@@ -359,7 +355,7 @@ private final class MutableTestClock: @unchecked Sendable {
private final class NoProgressRateLimitRefreshBackend: PreviewCodexReviewStoreBackend {
private(set) var refreshedAccountKeys: [String] = []
- init(account: CodexAccount) {
+ init(account: CodexReviewAccount) {
super.init(seed: .init(
initialAccount: account,
initialAccounts: [account]
@@ -380,7 +376,7 @@ private final class BlockingRateLimitRefreshBackend: PreviewCodexReviewStoreBack
private let releaseGate = AsyncGate()
private(set) var refreshedAccountKeys: [String] = []
- init(account: CodexAccount) {
+ init(account: CodexReviewAccount) {
super.init(seed: .init(
initialAccount: account,
initialAccounts: [account]
diff --git a/Tests/CodexReviewTests/ParsedReviewResultTests.swift b/Tests/CodexReviewKitTests/ParsedReviewResultTests.swift
similarity index 98%
rename from Tests/CodexReviewTests/ParsedReviewResultTests.swift
rename to Tests/CodexReviewKitTests/ParsedReviewResultTests.swift
index 49021b94..366e7455 100644
--- a/Tests/CodexReviewTests/ParsedReviewResultTests.swift
+++ b/Tests/CodexReviewKitTests/ParsedReviewResultTests.swift
@@ -1,5 +1,5 @@
import Testing
-@testable import CodexReview
+@testable import CodexReviewKit
@Suite("Parsed review results")
struct ParsedReviewResultTests {
diff --git a/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift b/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift
new file mode 100644
index 00000000..ea05c236
--- /dev/null
+++ b/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift
@@ -0,0 +1,38 @@
+import Foundation
+import Testing
+@testable import CodexReviewKit
+
+@Suite("Review run core")
+@MainActor
+struct ReviewRunCoreTests {
+ @Test func coreKeepsLifecycleMessageOnly() {
+ let succeeded = ReviewRunCore(
+ lifecycle: .init(status: .succeeded),
+ lifecycleMessage: "Succeeded."
+ )
+ #expect(succeeded.lifecycle.status == .succeeded)
+ #expect(succeeded.lifecycleMessage == "Succeeded.")
+
+ let failed = ReviewRunCore(
+ lifecycle: .init(
+ status: .failed,
+ errorMessage: "Backend failed."
+ ),
+ lifecycleMessage: "Failed."
+ )
+ #expect(failed.lifecycle.status == .failed)
+ #expect(failed.lifecycle.errorMessage == "Backend failed.")
+ #expect(failed.lifecycleMessage == "Failed.")
+
+ let cancelled = ReviewRunCore(
+ lifecycle: .init(
+ status: .cancelled,
+ cancellation: .mcpClient(message: "Session closed.")
+ ),
+ lifecycleMessage: "Cancelled."
+ )
+ #expect(cancelled.lifecycle.status == .cancelled)
+ #expect(cancelled.lifecycle.cancellation?.message == "Session closed.")
+ #expect(cancelled.lifecycleMessage == "Cancelled.")
+ }
+}
diff --git a/Tests/CodexReviewMCPAdapterTests/ReviewMCPProjectionTests.swift b/Tests/CodexReviewMCPAdapterTests/ReviewMCPProjectionTests.swift
deleted file mode 100644
index 6463fecb..00000000
--- a/Tests/CodexReviewMCPAdapterTests/ReviewMCPProjectionTests.swift
+++ /dev/null
@@ -1,172 +0,0 @@
-import Foundation
-import Testing
-@testable import CodexReviewMCPAdapter
-import CodexReviewDomain
-
-@MainActor
-@Suite("review MCP projection")
-struct ReviewMCPProjectionTests {
- @Test func capturesTimelineOrderActiveItemsAndLatestActivity() throws {
- let timeline = ReviewTimeline()
- let startedAt = Date(timeIntervalSince1970: 10)
- let updatedAt = Date(timeIntervalSince1970: 12)
-
- timeline.apply(.itemStarted(.init(
- id: "command-1",
- kind: .commandExecution,
- family: .command,
- phase: .running,
- content: .command(.init(command: "swift test", cwd: "/tmp/project")),
- startedAt: startedAt
- )), at: startedAt)
- timeline.apply(.itemStarted(.init(
- id: "tool-1",
- kind: .mcpToolCall,
- family: .tool,
- phase: .running,
- content: .toolCall(.init(server: "codex_review", tool: "review_read", arguments: "{\"jobId\":\"job-1\"}"))
- )), at: updatedAt)
-
- let projection = ReviewMCPProjection(timeline: timeline)
-
- #expect(projection.timelineRevision == timeline.revision)
- #expect(projection.orderedItemIDs.map(\.rawValue) == ["command-1", "tool-1"])
- #expect(projection.activeItemIDs.map(\.rawValue) == ["command-1", "tool-1"])
- #expect(projection.activeItemCount == 2)
- #expect(projection.latestActivityID?.rawValue == "tool-1")
- #expect(projection.items.map(\.id.rawValue) == ["command-1", "tool-1"])
- #expect(projection.items.map(\.isActive) == [true, true])
-
- let command = try #require(projection.items.first)
- #expect(command.kind == .commandExecution)
- #expect(command.family == .command)
- #expect(command.phase == .running)
- #expect(command.startedAt == startedAt)
- guard case .command(let commandContent) = command.content else {
- Issue.record("Expected command content")
- return
- }
- #expect(commandContent.command == "swift test")
- #expect(commandContent.cwd == "/tmp/project")
-
- let toolCall = try #require(projection.items.last)
- guard case .toolCall(let toolCallContent) = toolCall.content else {
- Issue.record("Expected tool call content")
- return
- }
- #expect(toolCallContent.server == "codex_review")
- #expect(toolCallContent.tool == "review_read")
- #expect(toolCallContent.arguments == "{\"jobId\":\"job-1\"}")
- }
-
- @Test func capturesToolCallProgress() throws {
- let timeline = ReviewTimeline()
- timeline.apply(.itemUpdated(.init(
- id: "tool-1:progress",
- kind: .mcpToolCall,
- family: .tool,
- phase: .running,
- content: .toolCall(.init(
- namespace: "mcp",
- server: "codex_review",
- tool: "review_read",
- progress: "Reading review job"
- ))
- )))
-
- let projection = ReviewMCPProjection(timeline: timeline)
- let item = try #require(projection.items.first)
- guard case .toolCall(let toolCall) = item.content else {
- Issue.record("Expected tool call content")
- return
- }
-
- #expect(toolCall.namespace == "mcp")
- #expect(toolCall.server == "codex_review")
- #expect(toolCall.tool == "review_read")
- #expect(toolCall.progress == "Reading review job")
- }
-
- @Test func capturesTerminalStateAndAllTimelineContentCases() throws {
- let timeline = ReviewTimeline()
- let contents: [(String, ReviewItemKind, ReviewItemFamily, ReviewTimelineItem.Content)] = [
- ("approval-1", "approval", .approval, .approval(.init(title: "Approve command", detail: "swift test"))),
- ("command-1", .commandExecution, .command, .command(.init(
- command: "swift test",
- cwd: "/tmp/project",
- output: "ok",
- exitCode: 0
- ))),
- ("context-1", .contextCompaction, .contextCompaction, .contextCompaction(.init(title: "Compacted context"))),
- ("diagnostic-1", "diagnostic", .diagnostic, .diagnostic(.init(message: "Network recovered"))),
- ("file-1", .fileChange, .fileChange, .fileChange(.init(title: "Sources/File.swift", output: "+ change"))),
- ("message-1", .agentMessage, .message, .message(.init(text: "Review complete"))),
- ("plan-1", .plan, .plan, .plan(.init(markdown: "- [x] Inspect"))),
- ("reasoning-1", .reasoning, .reasoning, .reasoning(.init(text: "Looks stable", style: .summary))),
- ("search-1", .webSearch, .search, .search(.init(query: "Swift MCP", result: "Result summary"))),
- ("tool-1", .mcpToolCall, .tool, .toolCall(.init(
- namespace: "mcp",
- server: "codex_review",
- tool: "review_read",
- result: "ok"
- ))),
- ("unknown-1", "custom", .unknown, .unknown(.init(title: "Custom item", detail: "Custom detail"))),
- ]
-
- for (offset, item) in contents.enumerated() {
- let completedAt = Date(timeIntervalSince1970: Double(100 + offset))
- timeline.apply(.itemCompleted(.init(
- id: .init(rawValue: item.0),
- kind: item.1,
- family: item.2,
- phase: .completed,
- content: item.3,
- completedAt: completedAt,
- durationMs: 25 + offset
- )), at: completedAt)
- }
- timeline.apply(.reviewCompleted(summary: "No findings.", result: "No correctness issues found."))
-
- let projection = ReviewMCPProjection(timeline: timeline)
-
- #expect(projection.orderedItemIDs.map(\.rawValue) == contents.map { $0.0 })
- #expect(projection.activeItemIDs.isEmpty)
- #expect(projection.activeItemCount == 0)
- #expect(projection.terminalSummary == "No findings.")
- #expect(projection.terminalResult == "No correctness issues found.")
- #expect(projection.items.map(\.content.type) == [
- "approval",
- "command",
- "contextCompaction",
- "diagnostic",
- "fileChange",
- "message",
- "plan",
- "reasoning",
- "search",
- "toolCall",
- "unknown",
- ])
-
- guard case .approval(let approval) = projection.items[0].content else {
- Issue.record("Expected approval content")
- return
- }
- #expect(approval.title == "Approve command")
- #expect(approval.detail == "swift test")
-
- guard case .fileChange(let fileChange) = projection.items[4].content else {
- Issue.record("Expected file change content")
- return
- }
- #expect(fileChange.title == "Sources/File.swift")
- #expect(fileChange.output == "+ change")
-
- guard case .search(let search) = projection.items[8].content else {
- Issue.record("Expected search content")
- return
- }
- #expect(search.query == "Swift MCP")
- #expect(search.result == "Result summary")
- }
-}
diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift
index 3aea152d..d99e36a7 100644
--- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift
+++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift
@@ -3,8 +3,8 @@ import Foundation
import MCP
@preconcurrency import NIOCore
import Testing
-@_spi(Testing) @testable import CodexReview
-import CodexReviewDomain
+@_spi(Testing) @testable import CodexReviewKit
+import CodexReviewKit
import CodexReviewMCPServer
import CodexReviewTesting
@@ -43,17 +43,18 @@ struct CodexReviewMCPHTTPServerTests {
let reviewRead = try #require(tools.first { $0["name"] as? String == "review_read" })
let readSchema = try #require(reviewRead["inputSchema"] as? [String: Any])
let readProperties = try #require(readSchema["properties"] as? [String: Any])
- #expect(readProperties["logOffset"] != nil)
- #expect(readProperties["logLimit"] != nil)
+ #expect(readProperties["logOffset"] == nil)
+ #expect(readProperties["logLimit"] == nil)
+ #expect(readProperties["logFilter"] == nil)
let reviewAwait = try #require(tools.first { $0["name"] as? String == "review_await" })
let awaitSchema = try #require(reviewAwait["inputSchema"] as? [String: Any])
let awaitProperties = try #require(awaitSchema["properties"] as? [String: Any])
- #expect(awaitProperties["jobId"] != nil)
+ #expect(awaitProperties["runId"] != nil)
#expect(awaitProperties["logOffset"] == nil)
let awaitAnyOf = try #require(awaitSchema["anyOf"] as? [[String: Any]])
let requiredAliases = awaitAnyOf.compactMap { $0["required"] as? [String] }
- #expect(requiredAliases.contains(["jobId"]))
- #expect(requiredAliases.contains(["jobID"]))
+ #expect(requiredAliases.contains(["runId"]))
+ #expect(requiredAliases.contains(["runID"]))
}
}
@@ -79,26 +80,28 @@ struct CodexReviewMCPHTTPServerTests {
],
],
])
- let response = await server.handleHTTPRequest(HTTPRequest(
- method: "POST",
- headers: [
- HTTPHeaderName.host: "review.local:9417",
- HTTPHeaderName.accept: "text/event-stream, application/json",
- HTTPHeaderName.contentType: "application/json",
- ],
- body: initializeBody,
- path: "/mcp"
- ))
- let denied = await server.handleHTTPRequest(HTTPRequest(
- method: "POST",
- headers: [
- HTTPHeaderName.host: "other.local:9417",
- HTTPHeaderName.accept: "text/event-stream, application/json",
- HTTPHeaderName.contentType: "application/json",
- ],
- body: initializeBody,
- path: "/mcp"
- ))
+ let response = await server.handleHTTPRequest(
+ HTTPRequest(
+ method: "POST",
+ headers: [
+ HTTPHeaderName.host: "review.local:9417",
+ HTTPHeaderName.accept: "text/event-stream, application/json",
+ HTTPHeaderName.contentType: "application/json",
+ ],
+ body: initializeBody,
+ path: "/mcp"
+ ))
+ let denied = await server.handleHTTPRequest(
+ HTTPRequest(
+ method: "POST",
+ headers: [
+ HTTPHeaderName.host: "other.local:9417",
+ HTTPHeaderName.accept: "text/event-stream, application/json",
+ HTTPHeaderName.contentType: "application/json",
+ ],
+ body: initializeBody,
+ path: "/mcp"
+ ))
#expect(response.statusCode == 200)
#expect(response.headers[HTTPHeaderName.sessionID]?.isEmpty == false)
@@ -118,17 +121,19 @@ struct CodexReviewMCPHTTPServerTests {
configuration: configuration
)
- #expect((classified as? CodexReviewMCPHTTPServer.Error) == .addressInUse(
- host: "127.0.0.1",
- port: 54321
- ))
+ #expect(
+ (classified as? CodexReviewMCPHTTPServer.Error)
+ == .addressInUse(
+ host: "127.0.0.1",
+ port: 54321
+ ))
}
@Test func streamableHTTPCallsReviewStartWithCustomTarget() async throws {
let backend = FakeCodexReviewBackend()
let store = CodexReviewStore.makeTestingStore(
backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
+ idGenerator: .init(next: { "run-1" })
)
try await withHTTPServer(store: store) { server in
@@ -155,21 +160,36 @@ struct CodexReviewMCPHTTPServerTests {
sessionID: sessionID,
bodyData: requestBody
)
- await backend.yield(.completed(summary: "Done", result: "review text"))
+ await backend.yield(.completed(finalReview: "No issues found."))
let resolved = try decodeSSEJSON(from: try await responseData)
#expect(resolved.value(for: ["result", "isError"]) as? Bool == false)
- #expect(resolved.value(for: ["result", "structuredContent", "jobId"]) as? String == "job-1")
- #expect(resolved.value(for: ["result", "structuredContent", "jobID"]) == nil)
+ #expect(resolved.value(for: ["result", "structuredContent", "runId"]) as? String == "run-1")
+ #expect(resolved.value(for: ["result", "structuredContent", "runID"]) == nil)
#expect(resolved.value(for: ["result", "structuredContent", "logs"]) == nil)
- #expect(resolved.value(for: ["result", "structuredContent", "lifecycle", "status"]) as? String == "succeeded")
- #expect(resolved.value(for: ["result", "structuredContent", "output", "review"]) as? String == "review text")
+ #expect(
+ resolved.value(for: ["result", "structuredContent", "lifecycle", "status"]) as? String == "succeeded")
+ #expect(
+ resolved.value(for: ["result", "structuredContent", "lifecycle", "message"]) as? String
+ == "Review completed.")
+ #expect(
+ resolved.value(for: ["result", "structuredContent", "review", "hasFinalReview"]) as? Bool == true)
+ #expect(
+ resolved.value(for: ["result", "structuredContent", "review", "finalReview"]) as? String
+ == "No issues found.")
+ #expect(
+ resolved.value(for: ["result", "structuredContent", "review", "reviewResult", "state"]) as? String
+ == "noFindings")
let commands = await backend.recordedCommands()
- #expect(commands.contains(.startReview(.init(
- jobID: "job-1",
- sessionID: sessionID,
- request: .init(cwd: "/tmp/project", target: .custom(instructions: "Focus on test coverage."))
- ))))
+ #expect(
+ commands.contains(
+ .startReview(
+ .init(
+ runID: "run-1",
+ sessionID: sessionID,
+ request: .init(
+ cwd: "/tmp/project", target: .custom(instructions: "Focus on test coverage."))
+ ))))
}
}
@@ -177,7 +197,7 @@ struct CodexReviewMCPHTTPServerTests {
let backend = FakeCodexReviewBackend()
let store = CodexReviewStore.makeTestingStore(
backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
+ idGenerator: .init(next: { "run-1" })
)
let configuration = CodexReviewMCPHTTPServer.Configuration(
port: 0,
@@ -208,13 +228,14 @@ struct CodexReviewMCPHTTPServerTests {
let running = try decodeSSEJSON(from: try await responseData)
#expect(running.value(for: ["result", "isError"]) as? Bool == false)
- #expect(running.value(for: ["result", "structuredContent", "jobId"]) as? String == "job-1")
+ #expect(running.value(for: ["result", "structuredContent", "runId"]) as? String == "run-1")
#expect(running.value(for: ["result", "structuredContent", "lifecycle", "status"]) as? String == "running")
#expect(running.value(for: ["result", "structuredContent", "logs"]) == nil)
#expect(running.value(for: ["result", "structuredContent", "rawLogText"]) == nil)
- #expect(running.value(for: ["result", "structuredContent", "nextAction", "tool"]) as? String == "review_await")
+ #expect(
+ running.value(for: ["result", "structuredContent", "nextAction", "tool"]) as? String == "review_await")
- await backend.yield(.completed(summary: "Done", result: "review text"))
+ await backend.yield(.completed(finalReview: "No issues found."))
let awaited = try await postJSONRPC(
endpoint: endpoint,
sessionID: sessionID,
@@ -225,17 +246,30 @@ struct CodexReviewMCPHTTPServerTests {
"params": [
"name": "review_await",
"arguments": [
- "jobId": "job-1",
+ "runId": "run-1"
],
],
]
)
#expect(awaited.value(for: ["result", "isError"]) as? Bool == false)
- #expect(awaited.value(for: ["result", "structuredContent", "lifecycle", "status"]) as? String == "succeeded")
- #expect(awaited.value(for: ["result", "structuredContent", "output", "review"]) as? String == "review text")
- #expect(awaited.value(for: ["result", "structuredContent", "timeline", "terminalSummary"]) as? String == "Done")
- #expect(awaited.value(for: ["result", "structuredContent", "timeline", "terminalResult"]) as? String == "review text")
+ #expect(
+ awaited.value(for: ["result", "structuredContent", "lifecycle", "status"]) as? String == "succeeded")
+ #expect(
+ awaited.value(for: ["result", "structuredContent", "lifecycle", "message"]) as? String
+ == "Review completed.")
+ #expect(
+ awaited.value(for: ["result", "structuredContent", "review", "hasFinalReview"]) as? Bool == true)
+ #expect(
+ awaited.value(for: ["result", "structuredContent", "review", "finalReview"]) as? String
+ == "No issues found.")
+ #expect(
+ awaited.value(for: ["result", "structuredContent", "review", "reviewResult", "state"]) as? String
+ == "noFindings")
+ #expect(
+ awaited.value(for: ["result", "structuredContent", "log", "finalLifecycleMessage"]) as? String
+ == nil)
+ #expect(awaited.value(for: ["result", "structuredContent", "log", "finalResult"]) is NSNull)
#expect(awaited.value(for: ["result", "structuredContent", "logs"]) == nil)
}
}
@@ -244,7 +278,7 @@ struct CodexReviewMCPHTTPServerTests {
let backend = FakeCodexReviewBackend()
let store = CodexReviewStore.makeTestingStore(
backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
+ idGenerator: .init(next: { "run-1" })
)
try await withHTTPServer(store: store) { server in
@@ -270,16 +304,20 @@ struct CodexReviewMCPHTTPServerTests {
sessionID: sessionID,
bodyData: requestBody
)
- await backend.yield(.completed(summary: "Done", result: "review text"))
+ await backend.yield(.completed(finalReview: "No issues found."))
let resolved = try decodeSSEJSON(from: try await responseData)
- #expect(resolved.value(for: ["result", "structuredContent", "jobId"]) as? String == "job-1")
+ #expect(resolved.value(for: ["result", "structuredContent", "runId"]) as? String == "run-1")
let commands = await backend.recordedCommands()
- #expect(commands.contains(.startReview(.init(
- jobID: "job-1",
- sessionID: sessionID,
- request: .init(cwd: "/tmp/project", target: .custom(instructions: "Focus on test coverage."))
- ))))
+ #expect(
+ commands.contains(
+ .startReview(
+ .init(
+ runID: "run-1",
+ sessionID: sessionID,
+ request: .init(
+ cwd: "/tmp/project", target: .custom(instructions: "Focus on test coverage."))
+ ))))
}
}
@@ -287,7 +325,7 @@ struct CodexReviewMCPHTTPServerTests {
let backend = FakeCodexReviewBackend()
let store = CodexReviewStore.makeTestingStore(
backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
+ idGenerator: .init(next: { "run-1" })
)
try await withHTTPServer(store: store) { server in
@@ -314,7 +352,7 @@ struct CodexReviewMCPHTTPServerTests {
let resolved = try decodeSSEJSON(from: try await responseData)
#expect(resolved.value(for: ["result", "isError"]) as? Bool == true)
- #expect(resolved.value(for: ["result", "structuredContent", "jobId"]) as? String == "job-1")
+ #expect(resolved.value(for: ["result", "structuredContent", "runId"]) as? String == "run-1")
#expect(resolved.value(for: ["result", "structuredContent", "lifecycle", "status"]) as? String == "failed")
}
}
@@ -326,24 +364,24 @@ struct CodexReviewMCPHTTPServerTests {
)
try await withHTTPServer(store: store) { server in
let sessionID = try await initializeSession(endpoint: await server.url)
- let included = CodexReviewJob.makeForTesting(
- id: "job-included",
+ let included = ReviewRunRecord.makeForTesting(
+ id: "run-included",
sessionID: sessionID,
cwd: "/tmp/project",
targetSummary: "Uncommitted changes",
status: .succeeded,
summary: "Done"
)
- let otherSession = CodexReviewJob.makeForTesting(
- id: "job-other-session",
+ let otherSession = ReviewRunRecord.makeForTesting(
+ id: "run-other-session",
sessionID: "other-session",
cwd: "/tmp/project",
targetSummary: "Uncommitted changes",
status: .succeeded,
summary: "Done"
)
- let otherWorkspace = CodexReviewJob.makeForTesting(
- id: "job-other-workspace",
+ let otherWorkspace = ReviewRunRecord.makeForTesting(
+ id: "run-other-workspace",
sessionID: sessionID,
cwd: "/tmp/other",
targetSummary: "Uncommitted changes",
@@ -352,11 +390,7 @@ struct CodexReviewMCPHTTPServerTests {
)
store.loadForTesting(
serverState: .running,
- workspaces: [
- .init(cwd: "/tmp/project"),
- .init(cwd: "/tmp/other"),
- ],
- jobs: [included, otherSession, otherWorkspace]
+ reviewRuns: [included, otherSession, otherWorkspace]
)
let response = try await postJSONRPC(
endpoint: await server.url,
@@ -377,7 +411,7 @@ struct CodexReviewMCPHTTPServerTests {
)
let items = try #require(response.value(for: ["result", "structuredContent", "items"]) as? [[String: Any]])
- #expect(items.compactMap { $0["jobId"] as? String } == ["job-included"])
+ #expect(items.compactMap { $0["runId"] as? String } == ["run-included"])
}
}
@@ -389,25 +423,20 @@ struct CodexReviewMCPHTTPServerTests {
try await withHTTPServer(store: store) { server in
let sessionID = try await initializeSession(endpoint: await server.url)
+ let includedRun = ReviewRunRecord.makeForTesting(
+ id: "run-in-session",
+ sessionID: sessionID,
+ cwd: "/tmp/project",
+ targetSummary: "Included",
+ status: .succeeded,
+ summary: "Done"
+ )
store.loadForTesting(
serverState: .running,
- workspaces: [.init(cwd: "/tmp/project")],
- jobs: [
- CodexReviewJob.makeForTesting(
- id: "job-in-session",
- sessionID: sessionID,
- cwd: "/tmp/project",
- targetSummary: "Included",
- status: .succeeded,
- summary: "Done",
- logEntries: [
- .init(kind: .command, groupID: "cmd-1", text: "$ swift test"),
- .init(kind: .commandOutput, groupID: "cmd-1", text: "Tests passed"),
- .init(kind: .agentMessage, text: "No correctness issues found."),
- ]
- ),
- CodexReviewJob.makeForTesting(
- id: "job-other-session",
+ reviewRuns: [
+ includedRun,
+ ReviewRunRecord.makeForTesting(
+ id: "run-other-session",
sessionID: "other-session",
cwd: "/tmp/project",
targetSummary: "Other",
@@ -426,7 +455,7 @@ struct CodexReviewMCPHTTPServerTests {
"method": "tools/call",
"params": [
"name": "review_read",
- "arguments": ["jobId": "job-in-session"],
+ "arguments": ["runId": "run-in-session"],
],
]
)
@@ -439,50 +468,25 @@ struct CodexReviewMCPHTTPServerTests {
"method": "tools/call",
"params": [
"name": "review_read",
- "arguments": ["jobID": "job-other-session"],
+ "arguments": ["runID": "run-other-session"],
],
]
)
- let allLogs = try await postJSONRPC(
- endpoint: await server.url,
- sessionID: sessionID,
- body: [
- "jsonrpc": "2.0",
- "id": 4,
- "method": "tools/call",
- "params": [
- "name": "review_read",
- "arguments": [
- "jobId": "job-in-session",
- "logFilter": "all",
- ],
- ],
- ]
- )
-
#expect(allowed.value(for: ["result", "isError"]) as? Bool == false)
- #expect(allowed.value(for: ["result", "structuredContent", "jobId"]) as? String == "job-in-session")
- let defaultLogs = allowed.value(for: ["result", "structuredContent", "logs"]) as? [[String: Any]]
- #expect(defaultLogs?.compactMap { $0["kind"] as? String } == ["command", "agentMessage"])
- #expect(allowed.value(for: ["result", "structuredContent", "logsPage", "total"]) as? Int == 2)
- #expect(allowed.value(for: ["result", "structuredContent", "logsPage", "offset"]) as? Int == 0)
- #expect(allowed.value(for: ["result", "structuredContent", "logsPage", "limit"]) as? Int == 100)
- #expect(allowed.value(for: ["result", "structuredContent", "logsPage", "returned"]) as? Int == 2)
- let unfilteredLogs = allLogs.value(for: ["result", "structuredContent", "logs"]) as? [[String: Any]]
- #expect(unfilteredLogs?.compactMap { $0["kind"] as? String } == [
- "command",
- "commandOutput",
- "agentMessage",
- ])
+ #expect(allowed.value(for: ["result", "structuredContent", "runId"]) as? String == "run-in-session")
+ #expect(allowed.value(for: ["result", "structuredContent", "logs"]) == nil)
+ #expect(allowed.value(for: ["result", "structuredContent", "logsPage"]) == nil)
let readText = (allowed.value(for: ["result", "content"]) as? [[String: Any]])?.first?["text"] as? String
#expect(readText == "Done")
#expect(readText?.contains("rawLogText") == false)
#expect(denied.value(for: ["result", "isError"]) as? Bool == true)
- #expect((denied.value(for: ["result", "content"]) as? [[String: Any]])?.first?["text"] as? String == "Job job-other-session was not found.")
+ #expect(
+ (denied.value(for: ["result", "content"]) as? [[String: Any]])?.first?["text"] as? String
+ == "Run run-other-session was not found.")
}
}
- @Test func streamableHTTPReviewReadAddsSemanticTimelineWithoutChangingLegacyShape() async throws {
+ @Test func streamableHTTPReviewReadLeavesLogEmptyWithoutChatProvider() async throws {
let backend = FakeCodexReviewBackend()
let store = CodexReviewStore.makeTestingStore(
backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
@@ -490,48 +494,17 @@ struct CodexReviewMCPHTTPServerTests {
try await withHTTPServer(store: store) { server in
let sessionID = try await initializeSession(endpoint: await server.url)
- let startedAt = Date(timeIntervalSince1970: 20)
- let completedAt = Date(timeIntervalSince1970: 23)
- let longOutput = "Tests passed\n" + String(repeating: "x", count: 4500)
- let commandMetadata = ReviewLogEntry.Metadata(
- sourceType: "commandExecution",
- status: "completed",
- itemID: "cmd-1",
- command: "swift test",
+ let runRecord = ReviewRunRecord.makeForTesting(
+ id: "run-semantic",
+ sessionID: sessionID,
cwd: "/tmp/project",
- exitCode: 0,
- startedAt: startedAt,
- completedAt: completedAt,
- durationMs: 3000,
- commandStatus: "completed"
+ targetSummary: "Included",
+ status: .succeeded,
+ summary: "Done"
)
store.loadForTesting(
serverState: .running,
- workspaces: [.init(cwd: "/tmp/project")],
- jobs: [
- CodexReviewJob.makeForTesting(
- id: "job-semantic",
- sessionID: sessionID,
- cwd: "/tmp/project",
- targetSummary: "Included",
- status: .succeeded,
- summary: "Done",
- logEntries: [
- .init(
- kind: .command,
- groupID: "cmd-1",
- text: "$ swift test",
- metadata: commandMetadata
- ),
- .init(
- kind: .commandOutput,
- groupID: "cmd-1",
- text: longOutput,
- metadata: commandMetadata
- ),
- ]
- ),
- ]
+ reviewRuns: [runRecord]
)
let defaultResponse = try await postJSONRPC(
@@ -544,74 +517,44 @@ struct CodexReviewMCPHTTPServerTests {
"params": [
"name": "review_read",
"arguments": [
- "jobId": "job-semantic",
- "logLimit": 1,
- ],
- ],
- ]
- )
- let allResponse = try await postJSONRPC(
- endpoint: await server.url,
- sessionID: sessionID,
- body: [
- "jsonrpc": "2.0",
- "id": 3,
- "method": "tools/call",
- "params": [
- "name": "review_read",
- "arguments": [
- "jobId": "job-semantic",
- "logFilter": "all",
- "logLimit": 2,
+ "runId": "run-semantic"
],
],
]
)
- #expect(defaultResponse.value(for: ["result", "structuredContent", "jobId"]) as? String == "job-semantic")
+ #expect(defaultResponse.value(for: ["result", "structuredContent", "runId"]) as? String == "run-semantic")
#expect(defaultResponse.value(for: ["result", "structuredContent", "run"]) != nil)
- #expect(defaultResponse.value(for: ["result", "structuredContent", "lifecycle", "status"]) as? String == "succeeded")
- #expect(defaultResponse.value(for: ["result", "structuredContent", "output", "summary"]) as? String == "Done")
- #expect(defaultResponse.value(for: ["result", "structuredContent", "rawLogText"]) as? String != nil)
- #expect(defaultResponse.value(for: ["result", "structuredContent", "logsPage", "total"]) as? Int == 1)
- let defaultLogs = try #require(defaultResponse.value(for: ["result", "structuredContent", "logs"]) as? [[String: Any]])
- #expect(defaultLogs.compactMap { $0["kind"] as? String } == ["command"])
-
- #expect(allResponse.value(for: ["result", "structuredContent", "logsPage", "total"]) as? Int == 2)
- let allLogs = try #require(allResponse.value(for: ["result", "structuredContent", "logs"]) as? [[String: Any]])
- #expect(allLogs.compactMap { $0["kind"] as? String } == ["command", "commandOutput"])
-
- let timeline = try #require(allResponse.value(for: ["result", "structuredContent", "timeline"]) as? [String: Any])
- #expect(timeline["orderedItemIds"] as? [String] == ["cmd-1"])
- #expect(timeline["activeItemIds"] as? [String] == [])
- #expect(timeline["activeItemCount"] as? Int == 0)
- #expect(timeline["latestActivityId"] as? String == "cmd-1")
- let itemsPage = try #require(timeline["itemsPage"] as? [String: Any])
- #expect(itemsPage["total"] as? Int == 1)
- #expect(itemsPage["limit"] as? Int == 2)
- #expect(itemsPage["returned"] as? Int == 1)
- let items = try #require(timeline["items"] as? [[String: Any]])
- let commandItem = try #require(items.first)
- #expect(commandItem["id"] as? String == "cmd-1")
- #expect(commandItem["kind"] as? String == "commandExecution")
- #expect(commandItem["family"] as? String == "command")
- #expect(commandItem["phase"] as? String == "completed")
- #expect(commandItem["isActive"] as? Bool == false)
- #expect(commandItem["durationMs"] as? Int == 3000)
- let content = try #require(commandItem["content"] as? [String: Any])
- #expect(content["type"] as? String == "command")
- #expect(content["command"] as? String == "swift test")
- #expect(content["cwd"] as? String == "/tmp/project")
- #expect(content["exitCode"] as? Int == 0)
- let timelineOutput = try #require(content["output"] as? String)
- #expect(timelineOutput.hasPrefix("Tests passed"))
- #expect(timelineOutput.hasSuffix("..."))
- #expect(timelineOutput.count < longOutput.count)
- #expect(content["truncatedFields"] as? [String] == ["output"])
+ #expect(
+ defaultResponse.value(for: ["result", "structuredContent", "lifecycle", "status"]) as? String
+ == "succeeded")
+ #expect(
+ defaultResponse.value(for: ["result", "structuredContent", "lifecycle", "message"]) as? String
+ == "Done")
+ #expect(
+ defaultResponse.value(for: ["result", "structuredContent", "review", "hasFinalReview"]) as? Bool
+ == false)
+ #expect(defaultResponse.value(for: ["result", "structuredContent", "review", "finalReview"]) is NSNull)
+ #expect(defaultResponse.value(for: ["result", "structuredContent", "logs"]) == nil)
+ #expect(defaultResponse.value(for: ["result", "structuredContent", "logsPage"]) == nil)
+ #expect(defaultResponse.value(for: ["result", "structuredContent", "rawLogText"]) == nil)
+
+ let log = try #require(
+ defaultResponse.value(for: ["result", "structuredContent", "log"]) as? [String: Any])
+ #expect(log["orderedEntryIds"] as? [String] == [])
+ #expect(log["activeEntryIds"] as? [String] == [])
+ #expect(log["activeEntryCount"] as? Int == 0)
+ #expect(log["latestEntryId"] is NSNull)
+ let itemsPage = try #require(log["itemsPage"] as? [String: Any])
+ #expect(itemsPage["total"] as? Int == 0)
+ #expect(itemsPage["limit"] as? Int == 100)
+ #expect(itemsPage["returned"] as? Int == 0)
+ let items = try #require(log["items"] as? [[String: Any]])
+ #expect(items.isEmpty)
}
}
- @Test func streamableHTTPReviewReadIncludesToolProgressInTimelineContent() async throws {
+ @Test func streamableHTTPReviewReadDoesNotProjectRunningSummaryAsLogContent() async throws {
let backend = FakeCodexReviewBackend()
let store = CodexReviewStore.makeTestingStore(
backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
@@ -619,30 +562,17 @@ struct CodexReviewMCPHTTPServerTests {
try await withHTTPServer(store: store) { server in
let sessionID = try await initializeSession(endpoint: await server.url)
- let job = CodexReviewJob.makeForTesting(
- id: "job-tool-progress",
+ let runRecord = ReviewRunRecord.makeForTesting(
+ id: "run-tool-progress",
sessionID: sessionID,
cwd: "/tmp/project",
targetSummary: "Included",
status: .running,
summary: "Running"
)
- job.timeline.apply(.itemUpdated(.init(
- id: "tool-1:progress",
- kind: .mcpToolCall,
- family: .tool,
- phase: .running,
- content: .toolCall(.init(
- namespace: "mcp",
- server: "codex_review",
- tool: "review_read",
- progress: "Reading review job"
- ))
- )))
store.loadForTesting(
serverState: .running,
- workspaces: [.init(cwd: "/tmp/project")],
- jobs: [job]
+ reviewRuns: [runRecord]
)
let response = try await postJSONRPC(
@@ -655,265 +585,18 @@ struct CodexReviewMCPHTTPServerTests {
"params": [
"name": "review_read",
"arguments": [
- "jobId": "job-tool-progress",
- "logFilter": "all",
+ "runId": "run-tool-progress"
],
],
]
)
- let timeline = try #require(response.value(for: ["result", "structuredContent", "timeline"]) as? [String: Any])
- #expect(timeline["activeItemIds"] as? [String] == ["tool-1:progress"])
- #expect(timeline["activeItemCount"] as? Int == 1)
- let items = try #require(timeline["items"] as? [[String: Any]])
- let item = try #require(items.first)
- #expect(item["id"] as? String == "tool-1:progress")
- #expect(item["isActive"] as? Bool == true)
- let content = try #require(item["content"] as? [String: Any])
- #expect(content["type"] as? String == "toolCall")
- #expect(content["namespace"] as? String == "mcp")
- #expect(content["server"] as? String == "codex_review")
- #expect(content["tool"] as? String == "review_read")
- #expect(content["progress"] as? String == "Reading review job")
- }
- }
-
- @Test func streamableHTTPReviewReadPagesTimelineFromRequestedOffsetWhenLogsAreFiltered() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
- )
-
- try await withHTTPServer(store: store) { server in
- let sessionID = try await initializeSession(endpoint: await server.url)
- store.loadForTesting(
- serverState: .running,
- workspaces: [.init(cwd: "/tmp/project")],
- jobs: [
- CodexReviewJob.makeForTesting(
- id: "job-filtered-timeline",
- sessionID: sessionID,
- cwd: "/tmp/project",
- targetSummary: "Included",
- status: .succeeded,
- summary: "Done",
- logEntries: [
- .init(
- kind: .commandOutput,
- text: "output-0",
- metadata: .init(
- sourceType: "fileChange",
- title: "File 0",
- status: "completed",
- itemID: "file-0"
- )
- ),
- .init(
- kind: .commandOutput,
- text: "output-1",
- metadata: .init(
- sourceType: "fileChange",
- title: "File 1",
- status: "completed",
- itemID: "file-1"
- )
- ),
- .init(
- kind: .commandOutput,
- text: "output-2",
- metadata: .init(
- sourceType: "fileChange",
- title: "File 2",
- status: "completed",
- itemID: "file-2"
- )
- ),
- ]
- ),
- ]
- )
-
- let firstPage = try await postJSONRPC(
- endpoint: await server.url,
- sessionID: sessionID,
- body: [
- "jsonrpc": "2.0",
- "id": 2,
- "method": "tools/call",
- "params": [
- "name": "review_read",
- "arguments": [
- "jobId": "job-filtered-timeline",
- "logOffset": 0,
- "logLimit": 1,
- ],
- ],
- ]
- )
- let secondPage = try await postJSONRPC(
- endpoint: await server.url,
- sessionID: sessionID,
- body: [
- "jsonrpc": "2.0",
- "id": 3,
- "method": "tools/call",
- "params": [
- "name": "review_read",
- "arguments": [
- "jobId": "job-filtered-timeline",
- "logOffset": 1,
- "logLimit": 1,
- ],
- ],
- ]
- )
-
- #expect(firstPage.value(for: ["result", "structuredContent", "logsPage", "total"]) as? Int == 0)
- let firstTimeline = try #require(firstPage.value(for: ["result", "structuredContent", "timeline"]) as? [String: Any])
- let firstItemsPage = try #require(firstTimeline["itemsPage"] as? [String: Any])
- #expect(firstItemsPage["total"] as? Int == 3)
- #expect(firstItemsPage["offset"] as? Int == 0)
- #expect(firstItemsPage["returned"] as? Int == 1)
- #expect(firstItemsPage["nextOffset"] as? Int == 1)
- let firstItems = try #require(firstTimeline["items"] as? [[String: Any]])
- #expect(firstItems.first?["id"] as? String == "file-0")
-
- let secondTimeline = try #require(secondPage.value(for: ["result", "structuredContent", "timeline"]) as? [String: Any])
- let secondItemsPage = try #require(secondTimeline["itemsPage"] as? [String: Any])
- #expect(secondItemsPage["offset"] as? Int == 1)
- #expect(secondItemsPage["previousOffset"] as? Int == 0)
- let secondItems = try #require(secondTimeline["items"] as? [[String: Any]])
- #expect(secondItems.first?["id"] as? String == "file-1")
- }
- }
-
- @Test func streamableHTTPReviewReadReturnsPagedRunningSummary() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- clock: .init(now: { Date(timeIntervalSince1970: 10) })
- )
-
- try await withHTTPServer(store: store) { server in
- let sessionID = try await initializeSession(endpoint: await server.url)
- let longLatest = "latest " + String(repeating: "x", count: 400)
- let entries = (0..<119).map { index in
- ReviewLogEntry(kind: .progress, text: "line-\(index)")
- } + [
- ReviewLogEntry(kind: .progress, text: longLatest),
- ]
- store.loadForTesting(
- serverState: .running,
- workspaces: [.init(cwd: "/tmp/project")],
- jobs: [
- CodexReviewJob.makeForTesting(
- id: "job-running",
- sessionID: sessionID,
- cwd: "/tmp/project",
- targetSummary: "Running",
- status: .running,
- startedAt: Date(timeIntervalSince1970: 5),
- summary: "Review started.",
- logEntries: entries
- ),
- ]
- )
-
- let response = try await postJSONRPC(
- endpoint: await server.url,
- sessionID: sessionID,
- body: [
- "jsonrpc": "2.0",
- "id": 2,
- "method": "tools/call",
- "params": [
- "name": "review_read",
- "arguments": [
- "jobId": "job-running",
- "logLimit": 5,
- ],
- ],
- ]
- )
-
- let logs = try #require(response.value(for: ["result", "structuredContent", "logs"]) as? [[String: Any]])
- let readText = try #require((response.value(for: ["result", "content"]) as? [[String: Any]])?.first?["text"] as? String)
- #expect(logs.compactMap { $0["text"] as? String }.first == "line-115")
- #expect(logs.count == 5)
- #expect(response.value(for: ["result", "structuredContent", "logsPage", "total"]) as? Int == 120)
- #expect(response.value(for: ["result", "structuredContent", "logsPage", "offset"]) as? Int == 115)
- #expect(response.value(for: ["result", "structuredContent", "logsPage", "limit"]) as? Int == 5)
- #expect(response.value(for: ["result", "structuredContent", "logsPage", "returned"]) as? Int == 5)
- #expect(response.value(for: ["result", "structuredContent", "logsPage", "hasMoreBefore"]) as? Bool == true)
- #expect(response.value(for: ["result", "structuredContent", "logsPage", "hasMoreAfter"]) as? Bool == false)
- #expect(response.value(for: ["result", "structuredContent", "logsPage", "previousOffset"]) as? Int == 110)
- #expect(response.value(for: ["result", "structuredContent", "logsPage", "nextOffset"]) is NSNull)
- #expect(readText.hasPrefix("Review running for 5s. Returned logs 116-120 of 120. Latest: latest "))
- #expect(readText.count < longLatest.count)
- #expect(readText.contains("Review started.") == false)
- }
- }
-
- @Test func streamableHTTPReviewReadRejectsInvalidPagingArguments() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
- )
-
- try await withHTTPServer(store: store) { server in
- let sessionID = try await initializeSession(endpoint: await server.url)
- store.loadForTesting(
- serverState: .running,
- workspaces: [.init(cwd: "/tmp/project")],
- jobs: [
- CodexReviewJob.makeForTesting(
- id: "job-running",
- sessionID: sessionID,
- cwd: "/tmp/project",
- targetSummary: "Running",
- status: .running,
- summary: "Review started."
- ),
- ]
- )
-
- let negativeOffset = try await postJSONRPC(
- endpoint: await server.url,
- sessionID: sessionID,
- body: [
- "jsonrpc": "2.0",
- "id": 2,
- "method": "tools/call",
- "params": [
- "name": "review_read",
- "arguments": [
- "jobId": "job-running",
- "logOffset": -1,
- ],
- ],
- ]
- )
- let tooLargeLimit = try await postJSONRPC(
- endpoint: await server.url,
- sessionID: sessionID,
- body: [
- "jsonrpc": "2.0",
- "id": 3,
- "method": "tools/call",
- "params": [
- "name": "review_read",
- "arguments": [
- "jobId": "job-running",
- "logLimit": CodexReviewAPI.Log.PageRequest.maxLimit + 1,
- ],
- ],
- ]
- )
-
- #expect(negativeOffset.value(for: ["result", "isError"]) as? Bool == true)
- #expect((negativeOffset.value(for: ["result", "content"]) as? [[String: Any]])?.first?["text"] as? String == "logOffset must be greater than or equal to 0.")
- #expect(tooLargeLimit.value(for: ["result", "isError"]) as? Bool == true)
- #expect((tooLargeLimit.value(for: ["result", "content"]) as? [[String: Any]])?.first?["text"] as? String == "logLimit must be between 1 and 500.")
+ let log = try #require(
+ response.value(for: ["result", "structuredContent", "log"]) as? [String: Any])
+ #expect(log["activeEntryIds"] as? [String] == [])
+ #expect(log["activeEntryCount"] as? Int == 0)
+ let items = try #require(log["items"] as? [[String: Any]])
+ #expect(items.isEmpty)
}
}
@@ -925,8 +608,8 @@ struct CodexReviewMCPHTTPServerTests {
try await withHTTPServer(store: store) { server in
let sessionID = try await initializeSession(endpoint: await server.url)
- let running = CodexReviewJob.makeForTesting(
- id: "job-running",
+ let running = ReviewRunRecord.makeForTesting(
+ id: "run-running",
sessionID: sessionID,
cwd: "/tmp/project",
targetSummary: "Uncommitted changes",
@@ -935,8 +618,8 @@ struct CodexReviewMCPHTTPServerTests {
status: .running,
summary: "Running"
)
- let otherSession = CodexReviewJob.makeForTesting(
- id: "job-other-session",
+ let otherSession = ReviewRunRecord.makeForTesting(
+ id: "run-other-session",
sessionID: "other-session",
cwd: "/tmp/project",
targetSummary: "Uncommitted changes",
@@ -947,8 +630,7 @@ struct CodexReviewMCPHTTPServerTests {
)
store.loadForTesting(
serverState: .running,
- workspaces: [.init(cwd: "/tmp/project")],
- jobs: [running, otherSession]
+ reviewRuns: [running, otherSession]
)
let response = try await postJSONRPC(
endpoint: await server.url,
@@ -969,20 +651,22 @@ struct CodexReviewMCPHTTPServerTests {
]
)
- #expect(response.value(for: ["result", "structuredContent", "jobId"]) as? String == "job-running")
+ #expect(response.value(for: ["result", "structuredContent", "runId"]) as? String == "run-running")
#expect(response.value(for: ["result", "structuredContent", "cancelled"]) as? Bool == true)
#expect(running.core.lifecycle.status == .cancelled)
#expect(running.core.lifecycle.cancellation?.message == "Stop from MCP")
#expect(otherSession.cancellationRequested == false)
let commands = await backend.recordedCommands()
- #expect(commands.contains(.interruptReview(
- .init(threadID: "thread-1", turnID: "turn-1", reviewThreadID: "thread-1", model: "gpt-5"),
- .init(message: "Stop from MCP")
- )))
+ #expect(
+ commands.contains(
+ .interruptReview(
+ .init(threadID: "thread-1", turnID: "turn-1", reviewThreadID: "thread-1", model: "gpt-5"),
+ .init(message: "Stop from MCP")
+ )))
}
}
- @Test func streamableHTTPCancelDefaultsSelectorToActiveJobsInTransportSession() async throws {
+ @Test func streamableHTTPCancelDefaultsSelectorToActiveRunsInTransportSession() async throws {
let backend = FakeCodexReviewBackend()
let store = CodexReviewStore.makeTestingStore(
backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
@@ -990,8 +674,8 @@ struct CodexReviewMCPHTTPServerTests {
try await withHTTPServer(store: store) { server in
let sessionID = try await initializeSession(endpoint: await server.url)
- let completed = CodexReviewJob.makeForTesting(
- id: "job-completed",
+ let completed = ReviewRunRecord.makeForTesting(
+ id: "run-completed",
sessionID: sessionID,
cwd: "/tmp/project",
targetSummary: "Completed",
@@ -1000,8 +684,8 @@ struct CodexReviewMCPHTTPServerTests {
status: .succeeded,
summary: "Done"
)
- let running = CodexReviewJob.makeForTesting(
- id: "job-running",
+ let running = ReviewRunRecord.makeForTesting(
+ id: "run-running",
sessionID: sessionID,
cwd: "/tmp/project",
targetSummary: "Running",
@@ -1012,8 +696,7 @@ struct CodexReviewMCPHTTPServerTests {
)
store.loadForTesting(
serverState: .running,
- workspaces: [.init(cwd: "/tmp/project")],
- jobs: [completed, running]
+ reviewRuns: [completed, running]
)
let response = try await postJSONRPC(
@@ -1033,7 +716,7 @@ struct CodexReviewMCPHTTPServerTests {
]
)
- #expect(response.value(for: ["result", "structuredContent", "jobId"]) as? String == "job-running")
+ #expect(response.value(for: ["result", "structuredContent", "runId"]) as? String == "run-running")
#expect(response.value(for: ["result", "structuredContent", "cancelled"]) as? Bool == true)
#expect(completed.core.lifecycle.status == .succeeded)
#expect(running.core.lifecycle.status == .cancelled)
@@ -1050,10 +733,9 @@ struct CodexReviewMCPHTTPServerTests {
let sessionID = try await initializeSession(endpoint: await server.url)
store.loadForTesting(
serverState: .running,
- workspaces: [.init(cwd: "/tmp/project")],
- jobs: [
- CodexReviewJob.makeForTesting(
- id: "job-running-1",
+ reviewRuns: [
+ ReviewRunRecord.makeForTesting(
+ id: "run-running-1",
sessionID: sessionID,
cwd: "/tmp/project",
targetSummary: "First",
@@ -1062,8 +744,8 @@ struct CodexReviewMCPHTTPServerTests {
status: .running,
summary: "Running"
),
- CodexReviewJob.makeForTesting(
- id: "job-running-2",
+ ReviewRunRecord.makeForTesting(
+ id: "run-running-2",
sessionID: sessionID,
cwd: "/tmp/project",
targetSummary: "Second",
@@ -1094,13 +776,13 @@ struct CodexReviewMCPHTTPServerTests {
let text = (response.value(for: ["result", "content"]) as? [[String: Any]])?.first?["text"] as? String
#expect(response.value(for: ["result", "isError"]) as? Bool == true)
- #expect(text?.contains("matched multiple jobs") == true)
- #expect(text?.contains("job-running-1") == true)
- #expect(text?.contains("job-running-2") == true)
+ #expect(text?.contains("matched multiple review runs") == true)
+ #expect(text?.contains("run-running-1") == true)
+ #expect(text?.contains("run-running-2") == true)
}
}
- @Test func streamableHTTPCancelsDocumentedJobId() async throws {
+ @Test func streamableHTTPCancelsDocumentedRunId() async throws {
let backend = FakeCodexReviewBackend()
let store = CodexReviewStore.makeTestingStore(
backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
@@ -1108,8 +790,8 @@ struct CodexReviewMCPHTTPServerTests {
try await withHTTPServer(store: store) { server in
let sessionID = try await initializeSession(endpoint: await server.url)
- let running = CodexReviewJob.makeForTesting(
- id: "job-running",
+ let running = ReviewRunRecord.makeForTesting(
+ id: "run-running",
sessionID: sessionID,
cwd: "/tmp/project",
targetSummary: "Running",
@@ -1120,8 +802,7 @@ struct CodexReviewMCPHTTPServerTests {
)
store.loadForTesting(
serverState: .running,
- workspaces: [.init(cwd: "/tmp/project")],
- jobs: [running]
+ reviewRuns: [running]
)
let response = try await postJSONRPC(
@@ -1134,14 +815,14 @@ struct CodexReviewMCPHTTPServerTests {
"params": [
"name": "review_cancel",
"arguments": [
- "jobId": "job-running",
+ "runId": "run-running",
"reason": "Stop from MCP",
],
],
]
)
- #expect(response.value(for: ["result", "structuredContent", "jobId"]) as? String == "job-running")
+ #expect(response.value(for: ["result", "structuredContent", "runId"]) as? String == "run-running")
#expect(running.core.lifecycle.status == .cancelled)
}
}
@@ -1152,7 +833,7 @@ struct CodexReviewMCPHTTPServerTests {
await backend.holdStartReview(with: gate)
let store = CodexReviewStore.makeTestingStore(
backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
+ idGenerator: .init(next: { "run-1" })
)
try await withHTTPServer(
@@ -1182,14 +863,14 @@ struct CodexReviewMCPHTTPServerTests {
await backend.waitForStartReview()
await server.runSessionCleanupForTesting(now: .distantFuture)
await gate.open()
- await backend.yield(.completed(summary: "Done", result: "review text"))
+ await backend.yield(.completed(finalReview: "No issues found."))
let resolved = try decodeSSEJSON(from: try await responseData)
- #expect(resolved.value(for: ["result", "structuredContent", "jobId"]) as? String == "job-1")
+ #expect(resolved.value(for: ["result", "structuredContent", "runId"]) as? String == "run-1")
#expect(resolved.value(for: ["result", "structuredContent", "logs"]) == nil)
#expect(resolved.value(for: ["result", "structuredContent", "rawLogText"]) == nil)
let startText = (resolved.value(for: ["result", "content"]) as? [[String: Any]])?.first?["text"] as? String
- #expect(startText == "review text")
+ #expect(startText == "No issues found.")
#expect(startText?.contains("rawLogText") == false)
let tools = try await postJSONRPC(
endpoint: endpoint,
@@ -1270,7 +951,7 @@ struct CodexReviewMCPHTTPServerTests {
}
}
- @Test func streamableHTTPKeepsJobIDCancellationInTransportSession() async throws {
+ @Test func streamableHTTPKeepsRunIDCancellationInTransportSession() async throws {
let backend = FakeCodexReviewBackend()
let store = CodexReviewStore.makeTestingStore(
backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
@@ -1278,8 +959,8 @@ struct CodexReviewMCPHTTPServerTests {
try await withHTTPServer(store: store) { server in
let sessionID = try await initializeSession(endpoint: await server.url)
- let other = CodexReviewJob.makeForTesting(
- id: "job-other-session",
+ let other = ReviewRunRecord.makeForTesting(
+ id: "run-other-session",
sessionID: "other-session",
cwd: "/tmp/project",
targetSummary: "Other",
@@ -1290,8 +971,7 @@ struct CodexReviewMCPHTTPServerTests {
)
store.loadForTesting(
serverState: .running,
- workspaces: [.init(cwd: "/tmp/project")],
- jobs: [other]
+ reviewRuns: [other]
)
let response = try await postJSONRPC(
@@ -1304,7 +984,7 @@ struct CodexReviewMCPHTTPServerTests {
"params": [
"name": "review_cancel",
"arguments": [
- "jobID": "job-other-session",
+ "runID": "run-other-session",
"reason": "Stop from MCP",
],
],
@@ -1324,8 +1004,8 @@ struct CodexReviewMCPHTTPServerTests {
try await withHTTPServer(store: store) { server in
let sessionID = try await initializeSession(endpoint: await server.url)
- let running = CodexReviewJob.makeForTesting(
- id: "job-running",
+ let running = ReviewRunRecord.makeForTesting(
+ id: "run-running",
sessionID: sessionID,
cwd: "/tmp/project",
targetSummary: "Running",
@@ -1336,8 +1016,7 @@ struct CodexReviewMCPHTTPServerTests {
)
store.loadForTesting(
serverState: .running,
- workspaces: [.init(cwd: "/tmp/project")],
- jobs: [running]
+ reviewRuns: [running]
)
let response = try await deleteSession(endpoint: await server.url, sessionID: sessionID)
@@ -1381,7 +1060,7 @@ struct CodexReviewMCPHTTPServerTests {
"id": 3,
"method": "resources/read",
"params": [
- "uri": "codex-review://help/targets/custom",
+ "uri": "codex-review://help/targets/custom"
],
]
)
@@ -1408,10 +1087,11 @@ struct CodexReviewMCPHTTPServerTests {
]
)
let templates = try #require(response.value(for: ["result", "resourceTemplates"]) as? [[String: Any]])
- #expect(templates.compactMap { $0["uriTemplate"] as? String } == [
- "codex-review://help/tools/{tool}",
- "codex-review://help/targets/{target}",
- ])
+ #expect(
+ templates.compactMap { $0["uriTemplate"] as? String } == [
+ "codex-review://help/tools/{tool}",
+ "codex-review://help/targets/{target}",
+ ])
}
}
@@ -1527,14 +1207,14 @@ struct CodexReviewMCPHTTPServerTests {
}
let request = """
- GET \(endpoint.path) HTTP/1.1\r
- Host: \(host):\(port)\r
- Accept: text/event-stream, application/json\r
- MCP-Session-Id: \(sessionID)\r
- Connection: close\r
- \r
-
- """
+ GET \(endpoint.path) HTTP/1.1\r
+ Host: \(host):\(port)\r
+ Accept: text/event-stream, application/json\r
+ MCP-Session-Id: \(sessionID)\r
+ Connection: close\r
+ \r
+
+ """
let bytes = Array(request.utf8)
try bytes.withUnsafeBytes { rawBuffer in
guard let baseAddress = rawBuffer.baseAddress else {
@@ -1605,16 +1285,17 @@ struct CodexReviewMCPHTTPServerTests {
private func decodeSSEJSON(from data: Data) throws -> [String: Any] {
let text = String(decoding: data, as: UTF8.self)
- let payload = try #require(text
- .split(separator: "\n")
- .compactMap { line -> String? in
- guard line.hasPrefix("data: ") else {
- return nil
+ let payload = try #require(
+ text
+ .split(separator: "\n")
+ .compactMap { line -> String? in
+ guard line.hasPrefix("data: ") else {
+ return nil
+ }
+ let value = line.dropFirst("data: ".count)
+ return value.isEmpty ? nil : String(value)
}
- let value = line.dropFirst("data: ".count)
- return value.isEmpty ? nil : String(value)
- }
- .last)
+ .last)
let jsonData = Data(payload.utf8)
return try #require(JSONSerialization.jsonObject(with: jsonData) as? [String: Any])
}
diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift
index 2361bf0c..6b51462a 100644
--- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift
+++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift
@@ -1,6 +1,6 @@
import Testing
-@testable import CodexReview
-import CodexReviewMCPServer
+@testable import CodexReviewKit
+@testable import CodexReviewMCPServer
import CodexReviewTesting
@Suite("MCP server adapter")
@@ -26,7 +26,7 @@ struct CodexReviewMCPServerTests {
let backend = FakeCodexReviewBackend()
let store = CodexReviewStore.makeTestingStore(
backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
+ idGenerator: .init(next: { "run-1" })
)
let server = CodexReviewMCPServer(store: store)
@@ -35,16 +35,19 @@ struct CodexReviewMCPServerTests {
request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
waitTimeout: nil
))
- await backend.yield(.completed(summary: "Done", result: "review"))
+ await backend.yield(.completed(finalReview: "No issues found."))
let resolved = try await response
- guard case .reviewRead(let read, let timeline, _) = resolved else {
- Issue.record("Expected reviewRead response")
+ guard case .reviewStart(let snapshot) = resolved else {
+ Issue.record("Expected reviewStart response")
return
}
- #expect(read.jobID == "job-1")
+ let read = snapshot.result
+ let log = snapshot.log
+ #expect(read.runID == "run-1")
#expect(read.core.lifecycle.status == .succeeded)
- #expect(timeline.terminalSummary == "Done")
- #expect(timeline.terminalResult == "review")
+ #expect(log.finalLifecycleMessage == nil)
+ #expect(log.finalResult == nil)
+ #expect(log.items.isEmpty)
}
}
diff --git a/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift b/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift
new file mode 100644
index 00000000..074e62ab
--- /dev/null
+++ b/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift
@@ -0,0 +1,111 @@
+import Foundation
+import Testing
+import CodexKit
+@testable import CodexReviewKit
+@testable import CodexReviewMCPServer
+
+@Suite("Review MCP log projection")
+struct ReviewMCPLogProjectionTests {
+ @Test func unavailableProjectionDoesNotRebuildLogFromRunLifecycle() throws {
+ let projection = ReviewMCPLogProjection.unavailable(result: .init(
+ runID: "run-1",
+ core: .init(
+ lifecycle: .init(status: .running),
+ lifecycleMessage: "Review started."
+ ),
+ cancellable: true
+ ))
+
+ #expect(projection.orderedEntryIDs == [])
+ #expect(projection.activeEntryIDs == [])
+ #expect(projection.activeEntryCount == 0)
+ #expect(projection.latestEntryID == nil)
+ #expect(projection.items.isEmpty)
+ #expect(projection.finalResult == nil)
+ }
+
+ @Test func unavailableTerminalProjectionDoesNotMirrorLifecycleAsLog() throws {
+ let projection = ReviewMCPLogProjection.unavailable(result: .init(
+ runID: "run-2",
+ core: .init(
+ lifecycle: .init(status: .succeeded, endedAt: Date(timeIntervalSince1970: 1_234)),
+ lifecycleMessage: "Done."
+ ),
+ cancellable: false
+ ))
+
+ #expect(projection.activeEntryIDs == [])
+ #expect(projection.activeEntryCount == 0)
+ #expect(projection.finalLifecycleMessage == nil)
+ #expect(projection.finalResult == nil)
+ #expect(projection.items.isEmpty)
+ }
+
+ @Test func turnItemsProjectAsOrderedLogItems() throws {
+ let projection = ReviewMCPLogProjection(
+ result: .init(
+ runID: "run-1",
+ core: .init(
+ run: .init(threadID: "thread-1", turnID: "turn-1"),
+ lifecycle: .init(status: .running),
+ lifecycleMessage: "Running."
+ ),
+ cancellable: true
+ ),
+ turnID: "turn-1",
+ threadItems: [
+ .init(
+ id: "assistant-1",
+ kind: .agentMessage,
+ content: .message(.init(id: "assistant-1", role: .assistant, text: "Inspecting files."))
+ ),
+ .init(
+ id: "reasoning-1",
+ kind: .reasoning,
+ content: .reasoning(.init(summary: "Need focused tests."))
+ ),
+ .init(
+ id: "command-1",
+ kind: .commandExecution,
+ content: .command(.init(command: "swift test", output: "passed"))
+ ),
+ ]
+ )
+
+ #expect(projection.orderedEntryIDs == [
+ "turn-1:assistant-1",
+ "turn-1:reasoning-1",
+ "turn-1:command-1",
+ ])
+ #expect(projection.activeEntryIDs == projection.orderedEntryIDs)
+ #expect(projection.activeEntryCount == 3)
+ #expect(projection.latestEntryID == "turn-1:command-1")
+ #expect(projection.items.map { $0.kind } == ["agentMessage", "reasoning", "commandExecution"])
+ #expect(projection.items.map { $0.content.type } == ["message", "reasoning", "command"])
+ }
+
+ @Test func terminalTurnItemsProvideFinalResultFromCodexChatOnly() throws {
+ let projection = ReviewMCPLogProjection(
+ result: .init(
+ runID: "run-1",
+ core: .init(
+ run: .init(threadID: "thread-1", turnID: "turn-1"),
+ lifecycle: .init(status: .succeeded, endedAt: Date(timeIntervalSince1970: 1_234)),
+ lifecycleMessage: "Done."
+ ),
+ cancellable: false
+ ),
+ turnID: "turn-1",
+ threadItems: [
+ .init(
+ id: "assistant-1",
+ kind: .agentMessage,
+ content: .message(.init(id: "assistant-1", role: .assistant, text: "CodexChat final"))
+ ),
+ ]
+ )
+
+ #expect(projection.finalLifecycleMessage == "Done.")
+ #expect(projection.finalResult == "CodexChat final")
+ }
+}
diff --git a/Tests/CodexReviewTests/CodexReviewJobRenderingTests.swift b/Tests/CodexReviewTests/CodexReviewJobRenderingTests.swift
deleted file mode 100644
index c889d7f2..00000000
--- a/Tests/CodexReviewTests/CodexReviewJobRenderingTests.swift
+++ /dev/null
@@ -1,359 +0,0 @@
-import Foundation
-import Testing
-@_spi(Testing) @testable import CodexReview
-import CodexReviewDomain
-
-@Suite("Codex review job rendering")
-@MainActor
-struct CodexReviewJobRenderingTests {
- @Test func renderedLogTextKeepsCommandOutputInSemanticProjection() {
- let job = CodexReviewJob.makeForTesting(
- id: "job-command-output",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .succeeded,
- summary: "Done",
- logEntries: [
- .init(kind: .command, text: "$ git diff --stat"),
- .init(kind: .commandOutput, groupID: "cmd-1", text: "README.md | 1 +"),
- .init(kind: .agentMessage, text: "No correctness issues found."),
- ]
- )
-
- #expect(job.logText == """
- $ git diff --stat
-
- README.md | 1 +
-
- No correctness issues found.
- """)
- #expect(job.activityLogText == """
- $ git diff --stat
-
- README.md | 1 +
- """)
- }
-
- @Test func outputOnlyCommandLogsDoNotSynthesizePlaceholderCommandLine() throws {
- let outputOnlyJob = CodexReviewJob.makeForTesting(
- id: "job-output-only-command",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(
- kind: .commandOutput,
- groupID: "cmd-output-only",
- text: "Build complete\n",
- metadata: .init(
- sourceType: "commandExecution",
- title: "Command output",
- itemID: "cmd-output-only"
- )
- ),
- ]
- )
-
- let outputOnlyItem = try #require(outputOnlyJob.timeline.item(for: "cmd-output-only"))
- guard case .command(let outputOnlyCommand) = outputOnlyItem.content else {
- Issue.record("Expected command timeline content.")
- return
- }
- #expect(outputOnlyCommand.command == "")
- #expect(outputOnlyJob.timelineLogEntries.map(\.kind) == [.commandOutput])
- #expect(outputOnlyJob.timelineLogEntries.map(\.text) == ["Build complete\n"])
-
- let mixedJob = CodexReviewJob.makeForTesting(
- id: "job-generic-command-title",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running"
- )
- mixedJob.timeline.apply(.itemCompleted(.init(
- id: "cmd-generic",
- kind: .commandExecution,
- family: .command,
- phase: .completed,
- content: .command(.init(command: "Command", output: "Build complete\n"))
- )))
-
- #expect(mixedJob.timelineLogEntries.map(\.kind) == [.commandOutput])
- #expect(mixedJob.timelineLogEntries.map(\.text) == ["Build complete\n"])
- }
-
- @Test func fileChangeCommandOutputChunksAccumulateInTimelineProjection() throws {
- let metadata = ReviewLogEntry.Metadata(
- sourceType: "fileChange",
- title: "Updated Sources/App.swift",
- status: "updated",
- itemID: "file-1"
- )
- let job = CodexReviewJob.makeForTesting(
- id: "job-file-change-output-chunks",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(
- kind: .commandOutput,
- groupID: "file-1",
- text: "Sources/App.swift | 1 +\n",
- metadata: metadata
- ),
- .init(
- kind: .commandOutput,
- groupID: "file-1",
- text: "+ new line\n",
- metadata: metadata
- ),
- ]
- )
-
- let item = try #require(job.timeline.item(for: "file-1"))
- guard case .fileChange(let fileChange) = item.content else {
- Issue.record("Expected file-change timeline content.")
- return
- }
- #expect(fileChange.output == "Sources/App.swift | 1 +\n+ new line\n")
- }
-
- @Test func metadataFreeCommandOutputAppendPreservesTerminalTimelineState() throws {
- let commandMetadata = ReviewLogEntry.Metadata(
- sourceType: "commandExecution",
- status: "completed",
- itemID: "cmd-1",
- command: "swift test",
- exitCode: 0,
- commandStatus: "completed"
- )
- let job = CodexReviewJob.makeForTesting(
- id: "job-command-output-after-completion",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .succeeded,
- summary: "Done",
- logEntries: [
- .init(
- kind: .command,
- groupID: "cmd-1",
- replacesGroup: true,
- text: "$ swift test",
- metadata: commandMetadata
- ),
- .init(kind: .commandOutput, groupID: "cmd-1", text: "Tests passed"),
- ]
- )
-
- let item = try #require(job.timeline.item(for: "cmd-1"))
- guard case .command(let command) = item.content else {
- Issue.record("Expected command timeline content.")
- return
- }
- #expect(item.phase == .completed)
- #expect(job.timeline.activeItemIDs.contains("cmd-1") == false)
- #expect(command.output == "Tests passed")
- #expect(command.exitCode == 0)
- }
-
- @Test func failedToolCallLogDoesNotCopyErrorTextIntoTimelineResult() throws {
- let job = CodexReviewJob.makeForTesting(
- id: "job-failed-tool-call",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(
- kind: .toolCall,
- groupID: "tool-1",
- text: "tool failed",
- metadata: .init(
- sourceType: "mcpToolCall",
- itemID: "tool-1",
- server: "codex_review",
- tool: "review_start",
- errorText: "tool failed"
- )
- ),
- ]
- )
-
- let item = try #require(job.timeline.items.first { $0.family == .tool })
- guard case .toolCall(let toolCall) = item.content else {
- Issue.record("Expected tool-call timeline content.")
- return
- }
- #expect(toolCall.error == "tool failed")
- #expect(toolCall.result == nil)
- }
-
- @Test func prebuiltTerminalJobsInitializeTimelineTerminalState() {
- let succeeded = CodexReviewJob.makeForTesting(
- id: "job-terminal-succeeded",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .succeeded,
- summary: "Succeeded.",
- hasFinalReview: true,
- lastAgentMessage: "No findings."
- )
- #expect(succeeded.timeline.terminalStatus == .succeeded)
- #expect(succeeded.timeline.terminalSummary == "Succeeded.")
- #expect(succeeded.timeline.terminalResult == "No findings.")
-
- let succeededWithoutFinalReview = CodexReviewJob.makeForTesting(
- id: "job-terminal-succeeded-no-review",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .succeeded,
- summary: "Succeeded.",
- hasFinalReview: false,
- lastAgentMessage: "Succeeded."
- )
- #expect(succeededWithoutFinalReview.timeline.terminalStatus == .succeeded)
- #expect(succeededWithoutFinalReview.timeline.terminalSummary == "Succeeded.")
- #expect(succeededWithoutFinalReview.timeline.terminalResult == nil)
-
- let failed = CodexReviewJob.makeForTesting(
- id: "job-terminal-failed",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .failed,
- summary: "Failed.",
- errorMessage: "Backend failed."
- )
- #expect(failed.timeline.terminalStatus == .failed)
- #expect(failed.timeline.terminalSummary == "Backend failed.")
- #expect(failed.timeline.terminalResult == nil)
-
- let cancelled = CodexReviewJob.makeForTesting(
- id: "job-terminal-cancelled",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .cancelled,
- cancellation: .mcpClient(message: "Session closed."),
- summary: "Cancelled."
- )
- #expect(cancelled.timeline.terminalStatus == .cancelled)
- #expect(cancelled.timeline.terminalSummary == "Session closed.")
- #expect(cancelled.timeline.terminalResult == nil)
- }
-
- @Test func tailAppendPublishesIncrementalLogMutation() {
- let job = CodexReviewJob.makeForTesting(
- id: "job-tail-append-mutation",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(kind: .agentMessage, groupID: "msg-1", text: "Initial")
- ]
- )
-
- let initialRevision = job.logRevision
- job.appendLogEntry(.init(kind: .agentMessage, groupID: "msg-1", text: " append"))
-
- #expect(job.logRevision == initialRevision + 1)
- #expect(job.lastLogMutation == .append)
- #expect(job.logText == "Initial append")
- }
-
- @Test func groupedReplacementPublishesReloadLogMutation() {
- let job = CodexReviewJob.makeForTesting(
- id: "job-grouped-replacement-mutation",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(kind: .plan, groupID: "plan-1", text: "- original")
- ]
- )
-
- let initialRevision = job.logRevision
- job.appendLogEntry(.init(
- kind: .plan,
- groupID: "plan-1",
- replacesGroup: true,
- text: "- updated"
- ))
-
- #expect(job.logRevision == initialRevision + 1)
- #expect(job.lastLogMutation == .reload)
- #expect(job.logEntries.count == 2)
- #expect(job.logText == "- updated")
- }
-
- @Test func runningRawReasoningOverLimitRemainsAppendOnly() {
- let initialText = String(repeating: "a", count: 250 * 1024)
- let delta = String(repeating: "b", count: 20 * 1024)
- let job = CodexReviewJob.makeForTesting(
- id: "job-live-raw-reasoning-limit",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(kind: .rawReasoning, groupID: "reasoning-1", text: initialText)
- ]
- )
-
- let initialRevision = job.logRevision
- job.appendLogEntry(.init(kind: .rawReasoning, groupID: "reasoning-1", text: delta))
-
- #expect(job.logRevision == initialRevision + 1)
- #expect(job.lastLogMutation == .append)
- #expect(job.logEntries.count == 2)
- #expect(job.logText.hasSuffix(delta))
- #expect(job.cappedLogBytes > 256 * 1024)
- }
-
- @Test func terminalRawReasoningTrimKeepsNewestTail() {
- let initialText = String(repeating: "a", count: 250 * 1024)
- let delta = String(repeating: "b", count: 20 * 1024)
- let job = CodexReviewJob.makeForTesting(
- id: "job-terminal-raw-reasoning-limit",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .succeeded,
- summary: "Done",
- logEntries: [
- .init(kind: .rawReasoning, groupID: "reasoning-1", text: initialText)
- ]
- )
-
- job.appendLogEntry(.init(kind: .rawReasoning, groupID: "reasoning-1", text: delta))
-
- #expect(job.lastLogMutation == .reload)
- #expect(job.logText.hasSuffix(delta))
- #expect(job.logEntries.last?.text == delta)
- #expect(job.cappedLogBytes <= 256 * 1024)
- }
-
- @Test func explicitReviewLogLimitApplicationPublishesReloadMutation() {
- let initialText = String(repeating: "a", count: 250 * 1024)
- let delta = String(repeating: "b", count: 20 * 1024)
- let job = CodexReviewJob.makeForTesting(
- id: "job-explicit-raw-reasoning-limit",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(kind: .rawReasoning, groupID: "reasoning-1", text: initialText)
- ]
- )
- job.appendLogEntry(.init(kind: .rawReasoning, groupID: "reasoning-1", text: delta))
- let appendRevision = job.logRevision
-
- #expect(job.applyReviewLogLimit())
- #expect(job.logRevision == appendRevision + 1)
- #expect(job.lastLogMutation == .reload)
- #expect(job.logText.hasSuffix(delta))
- #expect(job.cappedLogBytes <= 256 * 1024)
- }
-}
diff --git a/Tests/CodexReviewTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewTests/CodexReviewStoreCommandTests.swift
deleted file mode 100644
index 17de817b..00000000
--- a/Tests/CodexReviewTests/CodexReviewStoreCommandTests.swift
+++ /dev/null
@@ -1,4234 +0,0 @@
-import Foundation
-import Testing
-@_spi(Testing) @testable import CodexReview
-import CodexReviewDomain
-import CodexReviewTesting
-
-@Suite("Codex review store", .serialized)
-@MainActor
-struct CodexReviewStoreCommandTests {
- @Test func reviewStartPublishesCompletedJobAndRetainsResult() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- clock: .init(now: { Date(timeIntervalSince1970: 1) }),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- )
- await backend.yield(.log("started"))
- await backend.yield(.completed(summary: "Succeeded.", result: "review text"))
- let read = try await result
-
- #expect(read.jobID == "job-1")
- #expect(read.core.lifecycle.status == .succeeded)
- #expect(read.core.output.lastAgentMessage == "review text")
- #expect(store.listReviews(sessionID: nil).items.map(\.jobID) == ["job-1"])
-
- let commands = await backend.recordedCommands()
- #expect(commands.contains(.cleanupReview(.init(
- threadID: "thread-1",
- turnID: "turn-1",
- reviewThreadID: "review-thread-1"
- ))))
- }
- }
-
- @Test func boundedReviewStartReturnsRunningSnapshotAndCanBeAwaitedLater() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
- waitTimeout: .milliseconds(20)
- )
- let running = try await result
-
- #expect(running.jobID == "job-1")
- #expect(running.core.lifecycle.status == .running)
- #expect(running.core.output.hasFinalReview == false)
-
- await backend.yield(.completed(summary: "Succeeded.", result: "review text"))
- let final = try await store.awaitReview(
- sessionID: "session-1",
- jobID: "job-1",
- timeout: .seconds(1)
- )
-
- #expect(final.core.lifecycle.status == .succeeded)
- #expect(final.core.output.lastAgentMessage == "review text")
- }
- }
-
- @Test func domainEventsMutateTimelineAndSuppressLegacyLogProjection() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- clock: .init(now: { Date(timeIntervalSince1970: 10) }),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
- waitTimeout: .milliseconds(20)
- )
- _ = try await result
-
- let itemID = ReviewTimelineItem.ID(rawValue: "msg-1")
- await backend.yield(.domainEvents([
- .itemStarted(.init(
- id: itemID,
- kind: .agentMessage,
- family: .message,
- phase: .running,
- content: .message(.init(text: ""))
- )),
- ], legacyProjectionSuppressionCount: 0))
- #expect(await waitUntil {
- store.job(id: "job-1")?.timeline.item(for: itemID) != nil
- })
- let originalItem = try #require(store.job(id: "job-1")?.timeline.item(for: itemID))
-
- await backend.yield(.domainEvents([
- .textDelta(
- itemID: itemID,
- kind: .agentMessage,
- family: .message,
- content: .message(.init(text: "")),
- delta: "domain text"
- ),
- ], legacyProjectionSuppressionCount: 1))
- #expect(await waitUntil {
- guard let item = store.job(id: "job-1")?.timeline.item(for: itemID),
- case .message(let message) = item.content
- else {
- return false
- }
- return message.text == "domain text"
- })
- let updatedItem = try #require(store.job(id: "job-1")?.timeline.item(for: itemID))
- #expect(originalItem === updatedItem)
-
- await backend.yield(.logEntry(
- kind: .agentMessage,
- text: " legacy text",
- groupID: "msg-1",
- replacesGroup: false
- ))
- #expect(await waitUntil {
- store.job(id: "job-1")?.logEntries.contains { $0.text == " legacy text" } == true
- })
-
- let job = try #require(store.job(id: "job-1"))
- #expect(job.timeline.items.count == 1)
- #expect(job.timeline.item(for: itemID) === originalItem)
- guard case .message(let message) = job.timeline.item(for: itemID)?.content else {
- Issue.record("expected direct message timeline content")
- return
- }
- #expect(message.text == "domain text")
-
- await backend.yield(.logEntry(
- kind: .diagnostic,
- text: "legacy-only diagnostic",
- groupID: nil,
- replacesGroup: false
- ))
- #expect(await waitUntil {
- store.job(id: "job-1")?.timeline.items.count == 2
- })
- #expect(store.job(id: "job-1")?.timeline.item(for: itemID) === originalItem)
- }
- }
-
- @Test func terminalCommandCompatibilityLogUpdatesDirectTimelineItem() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
- waitTimeout: .milliseconds(20)
- )
- _ = try await result
-
- let itemID = ReviewTimelineItem.ID(rawValue: "cmd-1")
- await backend.yield(.domainEvents([
- .itemStarted(.init(
- id: itemID,
- kind: .commandExecution,
- family: .command,
- phase: .running,
- content: .command(.init(command: "swift test"))
- )),
- ], legacyProjectionSuppressionCount: 0))
- #expect(await waitUntil {
- store.job(id: "job-1")?.timeline.activeItemIDs.contains(itemID) == true
- })
- let originalItem = try #require(store.job(id: "job-1")?.timeline.item(for: itemID))
-
- await backend.yield(.logEntry(
- kind: .command,
- text: "$ swift test",
- groupID: "cmd-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "commandExecution",
- status: "completed",
- itemID: "cmd-1",
- command: "swift test",
- commandStatus: "completed"
- )
- ))
- #expect(await waitUntil {
- guard let item = store.job(id: "job-1")?.timeline.item(for: itemID) else {
- return false
- }
- return item.phase == .completed
- && store.job(id: "job-1")?.timeline.activeItemIDs.contains(itemID) == false
- })
-
- let updatedItem = try #require(store.job(id: "job-1")?.timeline.item(for: itemID))
- #expect(originalItem === updatedItem)
- }
- }
-
- @Test func skippedLegacyDeltaConsumesDirectProjectionSuppression() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
- waitTimeout: .milliseconds(20)
- )
- _ = try await result
-
- let itemID = ReviewTimelineItem.ID(rawValue: "msg-1")
- await backend.yield(.domainEvents([
- .itemUpdated(.init(
- id: itemID,
- kind: .agentMessage,
- family: .message,
- phase: .completed,
- content: .message(.init(text: "final"))
- )),
- ], legacyProjectionSuppressionCount: 1))
- await backend.yield(.logEntry(
- kind: .agentMessage,
- text: "final",
- groupID: "msg-1",
- replacesGroup: true
- ))
- #expect(await waitUntil {
- store.job(id: "job-1")?.logEntries.contains { $0.text == "final" } == true
- })
-
- await backend.yield(.domainEvents([
- .textDelta(
- itemID: itemID,
- kind: .agentMessage,
- family: .message,
- content: .message(.init(text: "")),
- delta: "late"
- ),
- ], legacyProjectionSuppressionCount: 1))
- await backend.yield(.messageDelta("late", itemID: "msg-1"))
- #expect(await waitUntil {
- store.job(id: "job-1")?.logEntries.contains { $0.text == "late" } == false
- })
- let messageItem = try #require(store.job(id: "job-1")?.timeline.item(for: itemID))
- guard case .message(let message) = messageItem.content else {
- Issue.record("expected message timeline content")
- return
- }
- #expect(message.text == "final")
-
- let timelineCount = try #require(store.job(id: "job-1")?.timeline.items.count)
- await backend.yield(.logEntry(
- kind: .diagnostic,
- text: "legacy-only diagnostic",
- groupID: nil,
- replacesGroup: false
- ))
- #expect(await waitUntil {
- store.job(id: "job-1")?.timeline.items.count == timelineCount + 1
- })
- }
- }
-
- @Test func inProgressAgentMessageReplacementAllowsFollowingDelta() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
- waitTimeout: .milliseconds(20)
- )
- _ = try await result
-
- let itemID = ReviewTimelineItem.ID(rawValue: "msg-1")
- await backend.yield(.domainEvents([
- .itemUpdated(.init(
- id: itemID,
- kind: .agentMessage,
- family: .message,
- phase: .running,
- content: .message(.init(text: "partial"))
- )),
- ], legacyProjectionSuppressionCount: 1))
- await backend.yield(.logEntry(
- kind: .agentMessage,
- text: "partial",
- groupID: "msg-1",
- replacesGroup: true,
- metadata: .init(sourceType: "agentMessage", status: "inProgress")
- ))
- await backend.yield(.domainEvents([
- .textDelta(
- itemID: itemID,
- kind: .agentMessage,
- family: .message,
- content: .message(.init(text: "")),
- delta: " delta"
- ),
- ], legacyProjectionSuppressionCount: 1))
- await backend.yield(.messageDelta(" delta", itemID: "msg-1"))
-
- #expect(await waitUntil {
- guard let job = store.job(id: "job-1"),
- let item = job.timeline.item(for: itemID),
- case .message(let message) = item.content
- else {
- return false
- }
- return message.text == "partial delta"
- && job.core.output.lastAgentMessage == "partial delta"
- && job.logEntries.contains { $0.kind == .agentMessage && $0.text == " delta" }
- })
- }
- }
-
- @Test func directTerminalErrorSuppressesCompatibleErrorLogTimelineProjection() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
- waitTimeout: .milliseconds(20)
- )
- _ = try await result
-
- let itemID = ReviewTimelineItem.ID(rawValue: "error:turn-1")
- let longError = String(repeating: "App-server failed.", count: 20_000)
- await backend.yield(.domainEvents([
- .itemUpdated(.init(
- id: itemID,
- kind: .init(rawValue: "error"),
- family: .diagnostic,
- phase: .failed,
- content: .diagnostic(.init(message: longError))
- )),
- ], legacyProjectionSuppressionCount: 0))
- await backend.yield(.suppressNextTerminalFailureLogTimelineProjection)
- await backend.yield(.failed(longError))
-
- #expect(await waitUntil {
- store.job(id: "job-1")?.core.lifecycle.status == .failed
- })
- let job = try #require(store.job(id: "job-1"))
- #expect(job.logEntries.contains { $0.kind == .error })
- let item = try #require(job.timeline.item(for: itemID))
- guard case .diagnostic(let diagnostic) = item.content else {
- Issue.record("expected diagnostic timeline content")
- return
- }
- #expect(diagnostic.message.count < longError.count)
- #expect(job.timeline.items.count == 1)
- }
- }
-
- @Test func directTimelineTextIsTrimmedWhenReviewLogLimitApplies() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
- waitTimeout: .milliseconds(20)
- )
- _ = try await result
-
- let itemID = ReviewTimelineItem.ID(rawValue: "msg-1")
- let longText = String(repeating: "x", count: 300_000)
- await backend.yield(.domainEvents([
- .itemStarted(.init(
- id: itemID,
- kind: .agentMessage,
- family: .message,
- phase: .running,
- content: .message(.init(text: ""))
- )),
- .textDelta(
- itemID: itemID,
- kind: .agentMessage,
- family: .message,
- content: .message(.init(text: "")),
- delta: longText
- ),
- ], legacyProjectionSuppressionCount: 1))
- await backend.yield(.logEntry(
- kind: .agentMessage,
- text: longText,
- groupID: "msg-1",
- replacesGroup: false
- ))
- #expect(await waitUntil {
- guard let item = store.job(id: "job-1")?.timeline.item(for: itemID),
- case .message(let message) = item.content
- else {
- return false
- }
- return message.text.count == longText.count
- })
-
- await backend.yield(.failed("Failed."))
- #expect(await waitUntil {
- store.job(id: "job-1")?.core.lifecycle.status == .failed
- })
- let item = try #require(store.job(id: "job-1")?.timeline.item(for: itemID))
- guard case .message(let message) = item.content else {
- Issue.record("expected message timeline content")
- return
- }
- #expect(message.text.count < longText.count)
- }
- }
-
- @Test func directFullItemTimelineTextIsTrimmedWhenReviewLogLimitApplies() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
- waitTimeout: .milliseconds(20)
- )
- _ = try await result
-
- let itemID = ReviewTimelineItem.ID(rawValue: "msg-1")
- let longText = String(repeating: "x", count: 300_000)
- await backend.yield(.domainEvents([
- .itemUpdated(.init(
- id: itemID,
- kind: .agentMessage,
- family: .message,
- phase: .completed,
- content: .message(.init(text: longText))
- )),
- ], legacyProjectionSuppressionCount: 1))
- await backend.yield(.logEntry(
- kind: .agentMessage,
- text: longText,
- groupID: "msg-1",
- replacesGroup: true
- ))
- #expect(await waitUntil {
- guard let item = store.job(id: "job-1")?.timeline.item(for: itemID),
- case .message(let message) = item.content
- else {
- return false
- }
- return message.text.count == longText.count
- })
-
- await backend.yield(.failed("Failed."))
- #expect(await waitUntil {
- store.job(id: "job-1")?.core.lifecycle.status == .failed
- })
- let item = try #require(store.job(id: "job-1")?.timeline.item(for: itemID))
- guard case .message(let message) = item.content else {
- Issue.record("expected message timeline content")
- return
- }
- #expect(message.text.count < longText.count)
- }
- }
-
- @Test func retainedCommandOutputChunksAppendWhenTrimmingDirectTimelineText() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
- waitTimeout: .milliseconds(20)
- )
- _ = try await result
-
- let itemID = ReviewTimelineItem.ID(rawValue: "cmd-1")
- let firstChunk = "first retained chunk\n"
- let secondChunk = "second retained chunk\n"
- await backend.yield(.domainEvents([
- .itemStarted(.init(
- id: itemID,
- kind: .commandExecution,
- family: .command,
- phase: .running,
- content: .command(.init(command: "swift test"))
- )),
- .textDelta(
- itemID: itemID,
- kind: .commandExecution,
- family: .command,
- content: .command(.init(command: "swift test")),
- delta: firstChunk
- ),
- .textDelta(
- itemID: itemID,
- kind: .commandExecution,
- family: .command,
- content: .command(.init(command: "swift test")),
- delta: secondChunk
- ),
- ], legacyProjectionSuppressionCount: 2))
- let outputMetadata = ReviewLogEntry.Metadata(
- sourceType: "commandExecution",
- title: "Command output",
- itemID: itemID.rawValue,
- command: "swift test"
- )
- await backend.yield(.logEntry(
- kind: .commandOutput,
- text: firstChunk,
- groupID: itemID.rawValue,
- replacesGroup: false,
- metadata: outputMetadata
- ))
- await backend.yield(.logEntry(
- kind: .commandOutput,
- text: secondChunk,
- groupID: itemID.rawValue,
- replacesGroup: false,
- metadata: outputMetadata
- ))
- await backend.yield(.logEntry(
- kind: .diagnostic,
- text: String(repeating: "x", count: 300_000),
- groupID: "diagnostic-1",
- replacesGroup: true
- ))
- #expect(await waitUntil {
- store.job(id: "job-1")?.logEntries.count == 3
- })
-
- let job = try #require(store.job(id: "job-1"))
- #expect(job.applyReviewLogLimit())
- let item = try #require(job.timeline.item(for: itemID))
- guard case .command(let command) = item.content else {
- Issue.record("expected command timeline content")
- return
- }
- #expect(command.output == firstChunk + secondChunk)
- }
- }
-
- @Test func syntheticDirectTimelineTextIsTrimmedThroughSuppressedCompatibilityLog() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
- waitTimeout: .milliseconds(20)
- )
- _ = try await result
-
- let itemID = ReviewTimelineItem.ID(rawValue: "msg-1")
- let longText = String(repeating: "x", count: 300_000)
- await backend.yield(.domainEvents([
- .itemUpdated(.init(
- id: itemID,
- kind: .agentMessage,
- family: .message,
- phase: .completed,
- content: .message(.init(text: longText))
- )),
- ], legacyProjectionSuppressionCount: 1))
- await backend.yield(.logEntry(
- kind: .agentMessage,
- text: longText,
- groupID: nil,
- replacesGroup: true
- ))
-
- await backend.yield(.failed("Failed."))
- #expect(await waitUntil {
- store.job(id: "job-1")?.core.lifecycle.status == .failed
- })
- let item = try #require(store.job(id: "job-1")?.timeline.item(for: itemID))
- guard case .message(let message) = item.content else {
- Issue.record("expected message timeline content")
- return
- }
- #expect(message.text.count < longText.count)
- }
- }
-
- @Test func eventCompatibilityLogTrimsDirectDiagnosticTimelineText() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
- waitTimeout: .milliseconds(20)
- )
- _ = try await result
-
- let itemID = ReviewTimelineItem.ID(rawValue: "diff-1")
- let longText = String(repeating: "diff --git\n", count: 40_000)
- await backend.yield(.domainEvents([
- .itemUpdated(.init(
- id: itemID,
- kind: .init(rawValue: "turn/diff/updated"),
- family: .diagnostic,
- phase: .completed,
- content: .diagnostic(.init(message: longText))
- )),
- ], legacyProjectionSuppressionCount: 1))
- await backend.yield(.logEntry(
- kind: .event,
- text: longText,
- groupID: itemID.rawValue,
- replacesGroup: true
- ))
- #expect(await waitUntil {
- store.job(id: "job-1")?.logEntries.contains {
- $0.kind == .event && $0.groupID == itemID.rawValue
- } == true
- })
-
- let job = try #require(store.job(id: "job-1"))
- #expect(job.applyReviewLogLimit())
- let item = try #require(job.timeline.item(for: itemID))
- guard case .diagnostic(let diagnostic) = item.content else {
- Issue.record("expected diagnostic timeline content")
- return
- }
- #expect(diagnostic.message.count < longText.count)
- }
- }
-
- @Test func diffCompatibilityLogTrimsDirectFileChangeTimelineText() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
- waitTimeout: .milliseconds(20)
- )
- _ = try await result
-
- let itemID = ReviewTimelineItem.ID(rawValue: "turn-1:turn/diff/updated")
- let longText = String(repeating: "diff --git\n", count: 40_000)
- await backend.yield(.domainEvents([
- .itemUpdated(.init(
- id: itemID,
- kind: .init(rawValue: "turn/diff/updated"),
- family: .fileChange,
- phase: .running,
- content: .fileChange(.init(title: "", output: longText))
- )),
- ], legacyProjectionSuppressionCount: 1))
- await backend.yield(.logEntry(
- kind: .event,
- text: longText,
- groupID: "turn-1",
- replacesGroup: true
- ))
- #expect(await waitUntil {
- store.job(id: "job-1")?.logEntries.contains {
- $0.kind == .event && $0.groupID == "turn-1"
- } == true
- })
-
- let job = try #require(store.job(id: "job-1"))
- #expect(job.applyReviewLogLimit())
- let item = try #require(job.timeline.item(for: itemID))
- guard case .fileChange(let fileChange) = item.content else {
- Issue.record("expected file-change timeline content")
- return
- }
- #expect(fileChange.output.count < longText.count)
- }
- }
-
- @Test func fileChangeOutputCompatibilityLogTrimsDirectFileChangeTimelineText() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
- waitTimeout: .milliseconds(20)
- )
- _ = try await result
-
- let itemID = ReviewTimelineItem.ID(rawValue: "file-1")
- let longText = String(repeating: "Sources/App.swift | 1 +\n+ new line\n", count: 20_000)
- await backend.yield(.domainEvents([
- .itemUpdated(.init(
- id: itemID,
- kind: .fileChange,
- family: .fileChange,
- phase: .running,
- content: .fileChange(.init(title: "Sources/App.swift", output: longText))
- )),
- ], legacyProjectionSuppressionCount: 1))
- await backend.yield(.logEntry(
- kind: .commandOutput,
- text: longText,
- groupID: itemID.rawValue,
- replacesGroup: true,
- metadata: .init(sourceType: "fileChange", title: "File changes", status: "updated")
- ))
- #expect(await waitUntil {
- store.job(id: "job-1")?.logEntries.contains {
- $0.kind == .commandOutput && $0.groupID == itemID.rawValue
- } == true
- })
-
- let job = try #require(store.job(id: "job-1"))
- #expect(job.applyReviewLogLimit())
- let item = try #require(job.timeline.item(for: itemID))
- guard case .fileChange(let fileChange) = item.content else {
- Issue.record("expected file-change timeline content")
- return
- }
- #expect(fileChange.output.count < longText.count)
- }
- }
-
- @Test func fileChangeStatusCompatibilityLogDoesNotTrimDirectPatchText() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
- waitTimeout: .milliseconds(20)
- )
- _ = try await result
-
- let itemID = ReviewTimelineItem.ID(rawValue: "file-1:patch")
- let patchText = String(repeating: "diff --git a/Sources/App.swift b/Sources/App.swift\n", count: 8_000)
- await backend.yield(.domainEvents([
- .itemUpdated(.init(
- id: itemID,
- kind: .init(rawValue: "item/fileChange/patchUpdated"),
- family: .fileChange,
- phase: .running,
- content: .fileChange(.init(title: "Sources/App.swift", output: patchText))
- )),
- ], legacyProjectionSuppressionCount: 1))
- await backend.yield(.logEntry(
- kind: .toolCall,
- text: "File changes updated.",
- groupID: "file-1",
- replacesGroup: false,
- metadata: .init(sourceType: "fileChange", title: "File changes", status: "updated")
- ))
- await backend.yield(.logEntry(
- kind: .diagnostic,
- text: String(repeating: "x", count: 300_000),
- groupID: "diagnostic-1",
- replacesGroup: true
- ))
- #expect(await waitUntil {
- store.job(id: "job-1")?.logEntries.count == 2
- })
-
- let job = try #require(store.job(id: "job-1"))
- #expect(job.applyReviewLogLimit())
- let item = try #require(job.timeline.item(for: itemID))
- guard case .fileChange(let fileChange) = item.content else {
- Issue.record("expected file-change timeline content")
- return
- }
- #expect(fileChange.output == patchText)
- }
- }
-
- @Test func searchCompatibilityLogTrimsDirectSearchResult() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
- waitTimeout: .milliseconds(20)
- )
- _ = try await result
-
- let itemID = ReviewTimelineItem.ID(rawValue: "search-1")
- let longResult = String(repeating: "Search result\n", count: 30_000)
- await backend.yield(.domainEvents([
- .itemUpdated(.init(
- id: itemID,
- kind: .webSearch,
- family: .search,
- phase: .completed,
- content: .search(.init(query: "ReviewTimeline", result: longResult))
- )),
- ], legacyProjectionSuppressionCount: 1))
- await backend.yield(.logEntry(
- kind: .toolCall,
- text: longResult,
- groupID: itemID.rawValue,
- replacesGroup: true,
- metadata: .init(
- sourceType: "webSearch",
- title: "Web search",
- status: "completed",
- query: "ReviewTimeline",
- resultText: longResult
- )
- ))
- await backend.yield(.logEntry(
- kind: .toolCall,
- text: "Web search completed: ReviewTimeline.",
- groupID: itemID.rawValue,
- replacesGroup: true,
- metadata: .init(
- sourceType: "webSearch",
- title: "Web search",
- status: "completed",
- query: "ReviewTimeline",
- resultText: longResult
- )
- ))
- #expect(await waitUntil {
- store.job(id: "job-1")?.logEntries.contains {
- $0.kind == .toolCall && $0.groupID == itemID.rawValue
- } == true
- })
-
- let job = try #require(store.job(id: "job-1"))
- #expect(job.applyReviewLogLimit())
- let item = try #require(job.timeline.item(for: itemID))
- guard case .search(let search) = item.content else {
- Issue.record("expected search timeline content")
- return
- }
- #expect(search.query == "ReviewTimeline")
- #expect((search.result ?? "").count < longResult.count)
- #expect((search.result ?? "").isEmpty == false)
- #expect(search.result != "Web search completed: ReviewTimeline.")
- }
- }
-
- @Test func searchCompletionSummaryCompatibilityLogDoesNotClearDirectResult() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
- waitTimeout: .milliseconds(20)
- )
- _ = try await result
-
- let itemID = ReviewTimelineItem.ID(rawValue: "search-1")
- await backend.yield(.domainEvents([
- .itemCompleted(.init(
- id: itemID,
- kind: .webSearch,
- family: .search,
- phase: .completed,
- content: .search(.init(query: "ReviewTimeline", result: "short result"))
- )),
- ], legacyProjectionSuppressionCount: 1))
- await backend.yield(.logEntry(
- kind: .toolCall,
- text: "Web search completed: ReviewTimeline.",
- groupID: itemID.rawValue,
- replacesGroup: true,
- metadata: .init(
- sourceType: "webSearch",
- title: "Web search",
- status: "completed",
- query: "ReviewTimeline",
- resultText: "short result"
- )
- ))
- await backend.yield(.logEntry(
- kind: .diagnostic,
- text: String(repeating: "x", count: 300_000),
- groupID: "diagnostic-1",
- replacesGroup: true
- ))
- #expect(await waitUntil {
- store.job(id: "job-1")?.logEntries.count == 2
- })
-
- let job = try #require(store.job(id: "job-1"))
- #expect(job.applyReviewLogLimit())
- let item = try #require(job.timeline.item(for: itemID))
- guard case .search(let search) = item.content else {
- Issue.record("expected search timeline content")
- return
- }
- #expect(search.result == "short result")
- }
- }
-
- @Test func unownedLargeResultMetadataIsDroppedBeforeLegacyTimelineProjection() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
- waitTimeout: .milliseconds(20)
- )
- _ = try await result
-
- let longResult = String(repeating: "Search result\n", count: 30_000)
- await backend.yield(.logEntry(
- kind: .toolCall,
- text: "Web search completed: ReviewTimeline.",
- groupID: "search-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "webSearch",
- title: "Web search",
- status: "completed",
- query: "ReviewTimeline",
- resultText: longResult
- )
- ))
- #expect(await waitUntil {
- store.job(id: "job-1")?.logEntries.count == 1
- })
-
- let job = try #require(store.job(id: "job-1"))
- let entry = try #require(job.logEntries.first)
- #expect(entry.metadata?.resultText == nil)
- let item = try #require(job.timeline.items.first)
- guard case .search(let search) = item.content else {
- Issue.record("expected search timeline content")
- return
- }
- #expect(search.query == "ReviewTimeline")
- #expect(search.result == nil)
- }
- }
-
- @Test func directRawReasoningTimelineTextTrimUsesLegacyGroupIDAndBumpsRevision() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
- waitTimeout: .milliseconds(20)
- )
- _ = try await result
-
- let itemID = ReviewTimelineItem.ID(rawValue: "reasoning-1:content:0")
- let longText = String(repeating: "r", count: 300_000)
- await backend.yield(.domainEvents([
- .textDelta(
- itemID: itemID,
- kind: .reasoning,
- family: .reasoning,
- content: .reasoning(.init(text: "", style: .raw)),
- delta: longText
- ),
- ], legacyProjectionSuppressionCount: 1))
- await backend.yield(.logEntry(
- kind: .rawReasoning,
- text: longText,
- groupID: "reasoning-1:0",
- replacesGroup: false
- ))
- #expect(await waitUntil {
- guard let item = store.job(id: "job-1")?.timeline.item(for: itemID),
- case .reasoning(let reasoning) = item.content
- else {
- return false
- }
- return reasoning.text.count == longText.count
- && store.job(id: "job-1")?.logEntries.contains {
- $0.kind == .rawReasoning && $0.groupID == "reasoning-1:0"
- } == true
- })
-
- let job = try #require(store.job(id: "job-1"))
- let revisionBeforeTrim = job.timeline.revision
- #expect(job.applyReviewLogLimit())
- #expect(job.timeline.revision > revisionBeforeTrim)
- let item = try #require(job.timeline.item(for: itemID))
- guard case .reasoning(let reasoning) = item.content else {
- Issue.record("expected reasoning timeline content")
- return
- }
- #expect(reasoning.text.count < longText.count)
- }
- }
-
- @Test func directPlanTimelineTextTrimUsesSyntheticTurnPlanID() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
- waitTimeout: .milliseconds(20)
- )
- _ = try await result
-
- let itemID = ReviewTimelineItem.ID(rawValue: "turn-1:turn/plan/updated")
- let longText = String(repeating: "- [pending] Review file\n", count: 15_000)
- await backend.yield(.domainEvents([
- .itemUpdated(.init(
- id: itemID,
- kind: .plan,
- family: .plan,
- phase: .running,
- content: .plan(.init(markdown: longText))
- )),
- ], legacyProjectionSuppressionCount: 1))
- await backend.yield(.logEntry(
- kind: .todoList,
- text: longText,
- groupID: "turn-1",
- replacesGroup: true
- ))
- #expect(await waitUntil {
- store.job(id: "job-1")?.logEntries.contains {
- $0.kind == .todoList && $0.groupID == "turn-1"
- } == true
- })
-
- let job = try #require(store.job(id: "job-1"))
- #expect(job.applyReviewLogLimit())
- let item = try #require(job.timeline.item(for: itemID))
- guard case .plan(let plan) = item.content else {
- Issue.record("expected plan timeline content")
- return
- }
- #expect(plan.markdown.count < longText.count)
- }
- }
-
- @Test func mcpToolCompletionLogDoesNotReplaceDirectProgressTextDuringTrim() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
- waitTimeout: .milliseconds(20)
- )
- _ = try await result
-
- let progressItemID = ReviewTimelineItem.ID(rawValue: "tool-1:progress")
- await backend.yield(.domainEvents([
- .itemUpdated(.init(
- id: progressItemID,
- kind: .mcpToolCall,
- family: .tool,
- phase: .running,
- content: .toolCall(.init(
- server: "codex_review",
- tool: "review_read",
- progress: "Reading review job"
- ))
- )),
- ], legacyProjectionSuppressionCount: 1))
- await backend.yield(.logEntry(
- kind: .toolCall,
- text: "Reading review job",
- groupID: "tool-1",
- replacesGroup: false,
- metadata: .init(sourceType: "mcpToolCall", title: "Tool progress")
- ))
- await backend.yield(.logEntry(
- kind: .toolCall,
- text: "codex_review.review_read completed. Result: ok",
- groupID: "tool-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "mcpToolCall",
- title: "codex_review.review_read",
- status: "completed",
- server: "codex_review",
- tool: "review_read",
- resultText: "ok"
- )
- ))
- await backend.yield(.logEntry(
- kind: .diagnostic,
- text: String(repeating: "x", count: 300_000),
- groupID: "diagnostic-1",
- replacesGroup: true
- ))
- #expect(await waitUntil {
- store.job(id: "job-1")?.logEntries.count == 3
- })
-
- let job = try #require(store.job(id: "job-1"))
- #expect(job.applyReviewLogLimit())
- let item = try #require(job.timeline.item(for: progressItemID))
- guard case .toolCall(let toolCall) = item.content else {
- Issue.record("expected tool progress timeline content")
- return
- }
- #expect(toolCall.progress == "Reading review job")
- #expect(try store.readReview(jobID: "job-1", logFilter: .all).logs.contains {
- $0.kind == .toolCall && $0.text == "Reading review job"
- })
- }
- }
-
- @Test func timelineProjectedReadReviewLogIDsAreStableAcrossReads() throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
- )
- let job = CodexReviewJob.makeForTesting(
- id: "job-1",
- cwd: "/tmp/project",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running"
- )
- job.timeline.apply(.itemUpdated(.init(
- id: "tool-1:progress",
- kind: .mcpToolCall,
- family: .tool,
- phase: .running,
- content: .toolCall(.init(
- server: "codex_review",
- tool: "review_read",
- progress: "Reading review job"
- ))
- )))
- job.timeline.apply(.itemCompleted(.init(
- id: "command-1",
- kind: .commandExecution,
- family: .command,
- phase: .completed,
- content: .command(.init(
- command: "swift test",
- output: "Tests passed",
- exitCode: 0
- ))
- )))
- store.loadForTesting(
- serverState: .running,
- workspaces: [.init(cwd: "/tmp/project")],
- jobs: [job]
- )
-
- let firstRead = try store.readReview(jobID: "job-1", logFilter: .all)
- let secondRead = try store.readReview(jobID: "job-1", logFilter: .all)
-
- #expect(firstRead.logs.map(\.text) == secondRead.logs.map(\.text))
- #expect(firstRead.logs.map(\.id) == secondRead.logs.map(\.id))
- #expect(Set(firstRead.logs.map(\.id)).count == firstRead.logs.count)
- }
-
- @Test func timelineProjectedReadReviewUsesOutputOnlyCommandText() throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
- )
- let job = CodexReviewJob.makeForTesting(
- id: "job-1",
- cwd: "/tmp/project",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running"
- )
- job.timeline.apply(.textDelta(
- itemID: "process-1",
- kind: .commandExecution,
- family: .command,
- content: .command(.init(command: "")),
- delta: "Build complete\n"
- ))
- store.loadForTesting(
- serverState: .running,
- workspaces: [.init(cwd: "/tmp/project")],
- jobs: [job]
- )
-
- let defaultRead = try store.readReview(jobID: "job-1")
- let allRead = try store.readReview(jobID: "job-1", logFilter: .all)
-
- #expect(defaultRead.logs.isEmpty)
- #expect(allRead.logs.map(\.kind) == [.commandOutput])
- #expect(allRead.logs.map(\.text) == ["Build complete\n"])
- #expect(allRead.logs.first?.metadata?.itemID == "process-1")
- #expect(allRead.logs.first?.metadata?.command == nil)
- }
-
- @Test func timelineProjectedReadReviewSuppressesGenericCommandTitle() throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
- )
- let job = CodexReviewJob.makeForTesting(
- id: "job-1",
- cwd: "/tmp/project",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running"
- )
- job.timeline.apply(.itemCompleted(.init(
- id: "process-1",
- kind: .commandExecution,
- family: .command,
- phase: .completed,
- content: .command(.init(command: "Command", output: "Build complete\n"))
- )))
- store.loadForTesting(
- serverState: .running,
- workspaces: [.init(cwd: "/tmp/project")],
- jobs: [job]
- )
-
- let allRead = try store.readReview(jobID: "job-1", logFilter: .all)
-
- #expect(allRead.logs.map(\.kind) == [.commandOutput])
- #expect(allRead.logs.map(\.text) == ["Build complete\n"])
- #expect(allRead.logs.first?.metadata?.command == nil)
- }
-
- @Test func timelineProjectedReadReviewPreservesCommandActions() throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
- )
- let action = ReviewLogEntry.Metadata.CommandAction(
- kind: .read,
- command: "cat Package.swift",
- name: "Package.swift",
- path: "Package.swift"
- )
- let timelineAction = ReviewTimelineItem.CommandAction(
- kind: .read,
- command: "cat Package.swift",
- name: "Package.swift",
- path: "Package.swift"
- )
- let job = CodexReviewJob.makeForTesting(
- id: "job-1",
- cwd: "/tmp/project",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(
- kind: .command,
- groupID: "cmd-1",
- replacesGroup: true,
- text: "$ cat Package.swift",
- metadata: .init(
- sourceType: "commandExecution",
- itemID: "cmd-1",
- command: "cat Package.swift",
- commandActions: [action]
- )
- )
- ]
- )
- store.loadForTesting(
- serverState: .running,
- workspaces: [.init(cwd: "/tmp/project")],
- jobs: [job]
- )
-
- let item = try #require(job.timeline.item(for: "cmd-1"))
- if case .command(let command) = item.content {
- #expect(command.actions == [timelineAction])
- } else {
- Issue.record("expected command timeline content")
- }
-
- let read = try store.readReview(jobID: "job-1", logFilter: .all)
- #expect(read.logs.map(\.text) == ["$ cat Package.swift"])
- #expect(read.logs.first?.metadata?.commandActions == [action])
- }
-
- @Test func directToolCallErrorTextIsTrimmedWhenReviewLogLimitApplies() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
- waitTimeout: .milliseconds(20)
- )
- _ = try await result
-
- let itemID = ReviewTimelineItem.ID(rawValue: "tool-error-1")
- let longError = String(repeating: "error", count: 60_000)
- await backend.yield(.domainEvents([
- .itemUpdated(.init(
- id: itemID,
- kind: .mcpToolCall,
- family: .tool,
- phase: .failed,
- content: .toolCall(.init(
- server: "codex_review",
- tool: "review_read",
- error: longError
- ))
- )),
- ], legacyProjectionSuppressionCount: 1))
- await backend.yield(.logEntry(
- kind: .toolCall,
- text: longError,
- groupID: itemID.rawValue,
- replacesGroup: true,
- metadata: .init(
- sourceType: "mcpToolCall",
- title: "codex_review.review_read",
- status: "failed",
- server: "codex_review",
- tool: "review_read",
- errorText: longError
- )
- ))
- #expect(await waitUntil {
- store.job(id: "job-1")?.logEntries.contains {
- $0.kind == .toolCall && $0.groupID == itemID.rawValue
- } == true
- })
-
- let job = try #require(store.job(id: "job-1"))
- #expect(job.applyReviewLogLimit())
- let item = try #require(job.timeline.item(for: itemID))
- guard case .toolCall(let toolCall) = item.content else {
- Issue.record("expected tool call timeline content")
- return
- }
- #expect((toolCall.error ?? "").count < longError.count)
- #expect((toolCall.error ?? "").isEmpty == false)
- }
- }
-
- @Test func directToolCallResultTextIsTrimmedWhenReviewLogLimitApplies() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
- waitTimeout: .milliseconds(20)
- )
- _ = try await result
-
- let itemID = ReviewTimelineItem.ID(rawValue: "tool-result-1")
- let longResult = String(repeating: "result", count: 60_000)
- await backend.yield(.domainEvents([
- .itemUpdated(.init(
- id: itemID,
- kind: .mcpToolCall,
- family: .tool,
- phase: .completed,
- content: .toolCall(.init(
- server: "codex_review",
- tool: "review_read",
- result: longResult
- ))
- )),
- ], legacyProjectionSuppressionCount: 1))
- await backend.yield(.logEntry(
- kind: .toolCall,
- text: longResult,
- groupID: itemID.rawValue,
- replacesGroup: true,
- metadata: .init(
- sourceType: "mcpToolCall",
- title: "codex_review.review_read",
- status: "completed",
- server: "codex_review",
- tool: "review_read",
- resultText: longResult
- )
- ))
- #expect(await waitUntil {
- store.job(id: "job-1")?.logEntries.contains {
- $0.kind == .toolCall && $0.groupID == itemID.rawValue
- } == true
- })
-
- let job = try #require(store.job(id: "job-1"))
- #expect(job.applyReviewLogLimit())
- let item = try #require(job.timeline.item(for: itemID))
- guard case .toolCall(let toolCall) = item.content else {
- Issue.record("expected tool call timeline content")
- return
- }
- #expect((toolCall.result ?? "").count < longResult.count)
- #expect((toolCall.result ?? "").isEmpty == false)
- }
- }
-
- @Test func mappedDirectTimelineTextClearsWhenCompatibilityLogIsRemovedByLimit() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
- waitTimeout: .milliseconds(20)
- )
- _ = try await result
-
- let firstItemID = ReviewTimelineItem.ID(rawValue: "msg-1")
- let secondItemID = ReviewTimelineItem.ID(rawValue: "msg-2")
- let firstText = String(repeating: "a", count: 300_000)
- let secondText = String(repeating: "b", count: 300_000)
- await backend.yield(.domainEvents([
- .itemUpdated(.init(
- id: firstItemID,
- kind: .agentMessage,
- family: .message,
- phase: .completed,
- content: .message(.init(text: firstText))
- )),
- ], legacyProjectionSuppressionCount: 1))
- await backend.yield(.logEntry(
- kind: .agentMessage,
- text: firstText,
- groupID: "msg-1",
- replacesGroup: true
- ))
- await backend.yield(.domainEvents([
- .itemUpdated(.init(
- id: secondItemID,
- kind: .agentMessage,
- family: .message,
- phase: .completed,
- content: .message(.init(text: secondText))
- )),
- ], legacyProjectionSuppressionCount: 1))
- await backend.yield(.logEntry(
- kind: .agentMessage,
- text: secondText,
- groupID: "msg-2",
- replacesGroup: true
- ))
- #expect(await waitUntil {
- store.job(id: "job-1")?.logEntries.count == 2
- })
-
- let job = try #require(store.job(id: "job-1"))
- #expect(job.applyReviewLogLimit())
- let firstItem = try #require(job.timeline.item(for: firstItemID))
- let secondItem = try #require(job.timeline.item(for: secondItemID))
- guard case .message(let firstMessage) = firstItem.content,
- case .message(let secondMessage) = secondItem.content
- else {
- Issue.record("expected message timeline content")
- return
- }
- #expect(firstMessage.text.isEmpty)
- #expect(secondMessage.text.count < secondText.count)
- }
- }
-
- @Test func legacyTimelineTextFromBeforeDirectEventsIsTrimmed() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
- waitTimeout: .milliseconds(20)
- )
- _ = try await result
-
- let longText = String(repeating: "legacy", count: 60_000)
- await backend.yield(.logEntry(
- kind: .agentMessage,
- text: longText,
- groupID: "legacy-message",
- replacesGroup: true
- ))
- #expect(await waitUntil {
- store.job(id: "job-1")?.timeline.items.contains { item in
- guard case .message(let message) = item.content else {
- return false
- }
- return message.text == longText
- } == true
- })
- let legacyItemID = try #require(store.job(id: "job-1")?.timeline.items.first { item in
- guard case .message(let message) = item.content else {
- return false
- }
- return message.text == longText
- }?.id)
-
- await backend.yield(.domainEvents([
- .itemUpdated(.init(
- id: .init(rawValue: "direct-message"),
- kind: .agentMessage,
- family: .message,
- phase: .completed,
- content: .message(.init(text: "direct"))
- )),
- ], legacyProjectionSuppressionCount: 0))
- #expect(await waitUntil {
- store.job(id: "job-1")?.timeline.item(for: .init(rawValue: "direct-message")) != nil
- })
-
- let job = try #require(store.job(id: "job-1"))
- #expect(job.applyReviewLogLimit())
- let item = try #require(job.timeline.item(for: legacyItemID))
- guard case .message(let message) = item.content else {
- Issue.record("expected message timeline content")
- return
- }
- #expect(message.text.count < longText.count)
- }
- }
-
- @Test func legacyProjectedTimelineTextIsTrimmedAfterDirectEvents() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
- waitTimeout: .milliseconds(20)
- )
- _ = try await result
-
- await backend.yield(.domainEvents([
- .itemUpdated(.init(
- id: .init(rawValue: "msg-1"),
- kind: .agentMessage,
- family: .message,
- phase: .completed,
- content: .message(.init(text: "direct"))
- )),
- ], legacyProjectionSuppressionCount: 0))
-
- let longText = String(repeating: "y", count: 300_000)
- await backend.yield(.logEntry(
- kind: .diagnostic,
- text: longText,
- groupID: "diagnostic-1",
- replacesGroup: true
- ))
- #expect(await waitUntil {
- store.job(id: "job-1")?.timeline.items.contains { item in
- guard case .diagnostic(let diagnostic) = item.content else {
- return false
- }
- return diagnostic.message == longText
- } == true
- })
- let legacyItemID = try #require(store.job(id: "job-1")?.timeline.items.first { item in
- guard case .diagnostic(let diagnostic) = item.content else {
- return false
- }
- return diagnostic.message == longText
- }?.id)
-
- await backend.yield(.failed("Failed."))
- #expect(await waitUntil {
- store.job(id: "job-1")?.core.lifecycle.status == .failed
- })
- let item = try #require(store.job(id: "job-1")?.timeline.item(for: legacyItemID))
- guard case .diagnostic(let diagnostic) = item.content else {
- Issue.record("expected diagnostic timeline content")
- return
- }
- #expect(diagnostic.message.count < longText.count)
- }
- }
-
- @Test func awaitReviewReturnsWhenRunningJobCompletes() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let start = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
- waitTimeout: .milliseconds(20)
- )
- _ = try await start
-
- async let awaited = store.awaitReview(
- sessionID: "session-1",
- jobID: "job-1",
- timeout: .seconds(1)
- )
- await backend.yield(.completed(summary: "Succeeded.", result: "review text"))
- let final = try await awaited
-
- #expect(final.core.lifecycle.status == .succeeded)
- #expect(final.core.output.lastAgentMessage == "review text")
- }
- }
-
- @Test func awaitReviewReturnsWhenRunningJobIsCancelled() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let start = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
- waitTimeout: .milliseconds(20)
- )
- _ = try await start
-
- async let awaited = store.awaitReview(
- sessionID: "session-1",
- jobID: "job-1",
- timeout: .seconds(1)
- )
- _ = try await store.cancelReview(
- jobID: "job-1",
- cancellation: .mcpClient(message: "Stop")
- )
- let final = try await awaited
-
- #expect(final.core.lifecycle.status == .cancelled)
- #expect(final.core.output.summary == "Stop")
- }
- }
-
- @Test func awaitReviewReturnsCurrentSnapshotOnTimeout() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let start = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
- waitTimeout: .milliseconds(20)
- )
- _ = try await start
-
- let snapshot = try await store.awaitReview(
- sessionID: "session-1",
- jobID: "job-1",
- timeout: .milliseconds(10)
- )
-
- #expect(snapshot.core.lifecycle.status == .running)
- #expect(snapshot.core.output.hasFinalReview == false)
- }
- }
-
- @Test func awaitReviewReturnsWhenLocalTerminationUpdatesTimeline() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let start = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges),
- waitTimeout: .milliseconds(20)
- )
- _ = try await start
-
- async let awaited = store.awaitReview(
- sessionID: "session-1",
- jobID: "job-1",
- timeout: .seconds(1)
- )
- await Task.yield()
- store.terminateAllRunningJobsLocally(
- failureMessage: "Review runtime stopped."
- )
- let final = try await awaited
-
- #expect(final.core.lifecycle.status == .failed)
- #expect(final.core.output.summary == "Failed to cancel review: Review runtime stopped.")
- }
- }
-
- @Test func forceStartWhileRunningInvokesBackendRestartPath() async {
- let reviewBackend = FakeCodexReviewBackend()
- let backend = TestingCodexReviewStoreBackend(reviewBackend: reviewBackend)
- let store = CodexReviewStore.makeTestingStore(backend: backend)
- await withStoreCommandTestCleanup(backend: reviewBackend, store: store) {
- await store.start()
- await store.start()
- await store.start(forceRestartIfNeeded: true)
-
- #expect(backend.startRequests == [false, true])
- }
- }
-
- @Test func reviewStartPassesEffectiveSettingsModelToBackend() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(
- reviewBackend: backend,
- seed: .init(initialSettingsSnapshot: .init(fallbackModel: "gpt-5.5"))
- ),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- )
- await backend.yield(.completed(summary: "Succeeded.", result: "review text"))
- _ = try await result
-
- let commands = await backend.recordedCommands()
- let starts = commands.compactMap { command -> CodexReviewBackendModel.Review.Start? in
- if case .startReview(let request) = command {
- return request
- }
- return nil
- }
- #expect(starts.first?.model == "gpt-5.5")
- }
- }
-
- @Test func reviewStartAppliesStartedTurnAndMergesAgentMessageDeltas() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- )
- await backend.yield(.started(turnID: "turn-actual", reviewThreadID: "review-thread-1", model: "gpt-5.5"))
- await backend.yield(.messageDelta("hello", itemID: "message-1"))
- await backend.yield(.messageDelta(" world", itemID: "message-1"))
- await backend.yield(.logEntry(
- kind: .reasoningSummary,
- text: " with space",
- groupID: "reasoning-1",
- replacesGroup: false
- ))
- await backend.yield(.completed(summary: "Succeeded.", result: nil))
- let read = try await result
-
- #expect(read.core.run.turnID == "turn-actual")
- #expect(read.core.output.lastAgentMessage == "hello world")
- #expect(read.rawLogText.isEmpty)
- #expect(try store.readReview(jobID: "job-1").logs.map(\.text) == [
- "hello world",
- " with space",
- ])
- #expect(try #require(store.job(id: "job-1")).reviewOutputText == "hello world\n\n with space")
- #expect(try store.readReview(jobID: "job-1").core.run.model == "gpt-5.5")
- }
- }
-
- @Test func reviewStartTracksAgentMessageDeltasByItemID() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- )
- await backend.yield(.messageDelta("first", itemID: "message-1"))
- await backend.yield(.messageDelta("second", itemID: "message-2"))
- await backend.yield(.completed(summary: "Succeeded.", result: nil))
- let read = try await result
-
- #expect(read.core.output.lastAgentMessage == "second")
- #expect(read.core.reviewText == "second")
- #expect(try store.readReview(jobID: "job-1").logs.map(\.text) == ["first", "second"])
- }
- }
-
- @Test func reviewCompletionDoesNotDuplicateAlreadyLoggedFinalMessage() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- )
- await backend.yield(.logEntry(
- kind: .agentMessage,
- text: "final review text",
- groupID: "review-item-1",
- replacesGroup: true
- ))
- await backend.yield(.completed(summary: "Succeeded.", result: "final review text"))
- let read = try await result
-
- #expect(read.core.output.lastAgentMessage == "final review text")
- #expect(read.core.reviewText == "final review text")
- #expect(try store.readReview(jobID: "job-1").logs.map(\.text) == ["final review text"])
- }
- }
-
- @Test func reviewCompletionEnforcesLogLimitWithoutFinalAppend() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- let initialText = String(repeating: "a", count: 250 * 1024)
- let delta = String(repeating: "b", count: 20 * 1024)
-
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- )
- await backend.yield(.logEntry(
- kind: .rawReasoning,
- text: initialText,
- groupID: "reasoning-1",
- replacesGroup: false
- ))
- await backend.yield(.logEntry(
- kind: .rawReasoning,
- text: delta,
- groupID: "reasoning-1",
- replacesGroup: false
- ))
- await backend.yield(.completed(summary: "Succeeded.", result: nil))
- let read = try await result
- let job = try #require(store.job(id: "job-1"))
-
- #expect(read.core.lifecycle.status == .succeeded)
- #expect(job.cappedLogBytes <= 256 * 1024)
- #expect(job.logText.hasSuffix(delta))
- #expect(job.lastLogMutation == .reload)
- }
- }
-
- @Test func readReviewDefaultsToCommandOutputFilteredLogs() throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
- )
- let job = CodexReviewJob.makeForTesting(
- id: "job-1",
- cwd: "/tmp/project",
- targetSummary: "Uncommitted changes",
- status: .succeeded,
- summary: "Done",
- logEntries: [
- .init(kind: .event, text: "Turn started: turn-1"),
- .init(kind: .progress, text: "Reviewing current changes"),
- .init(kind: .command, groupID: "cmd-1", text: "$ swift test"),
- .init(kind: .commandOutput, groupID: "cmd-1", text: "Tests passed"),
- .init(kind: .plan, groupID: "plan-1", text: "Plan text"),
- .init(kind: .todoList, groupID: "turn-1", text: "[inProgress] Inspect diff"),
- .init(kind: .reasoningSummary, groupID: "reasoning-1:summary:0", text: "Reasoning summary"),
- .init(kind: .rawReasoning, groupID: "reasoning-1:0", text: "Raw reasoning"),
- .init(kind: .toolCall, groupID: "tool-1", text: "MCP tool started"),
- .init(kind: .diagnostic, text: "Warning"),
- .init(kind: .error, text: "Recoverable error"),
- .init(kind: .agentMessage, text: "No correctness issues found."),
- ]
- )
- store.loadForTesting(
- serverState: .running,
- workspaces: [.init(cwd: "/tmp/project")],
- jobs: [job]
- )
-
- #expect(try store.readReview(jobID: "job-1").logs.map(\.kind) == [
- .event,
- .progress,
- .command,
- .plan,
- .todoList,
- .reasoningSummary,
- .rawReasoning,
- .toolCall,
- .diagnostic,
- .error,
- .agentMessage,
- ])
- #expect(try store.readReview(jobID: "job-1", logFilter: .all).logs.map(\.kind) == [
- .event,
- .progress,
- .command,
- .commandOutput,
- .plan,
- .todoList,
- .reasoningSummary,
- .rawReasoning,
- .toolCall,
- .diagnostic,
- .error,
- .agentMessage,
- ])
- }
-
- @Test func readReviewDefaultsToLatestPagedLogs() throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
- )
- let entries = (0..<125).map { index in
- ReviewLogEntry(kind: .progress, text: "line-\(index)")
- }
- let job = CodexReviewJob.makeForTesting(
- id: "job-1",
- cwd: "/tmp/project",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: entries
- )
- store.loadForTesting(
- serverState: .running,
- workspaces: [.init(cwd: "/tmp/project")],
- jobs: [job]
- )
-
- let read = try store.readReview(jobID: "job-1")
-
- #expect(read.logs.map(\.text).first == "line-25")
- #expect(read.logs.map(\.text).last == "line-124")
- #expect(read.logsPage == CodexReviewAPI.Log.Page(
- total: 125,
- offset: 25,
- limit: 100,
- returned: 100,
- hasMoreBefore: true,
- hasMoreAfter: false,
- previousOffset: 0,
- nextOffset: nil
- ))
- }
-
- @Test func readReviewReturnsRequestedLogPage() throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
- )
- let entries = (0..<12).map { index in
- ReviewLogEntry(kind: .progress, text: "line-\(index)")
- }
- let job = CodexReviewJob.makeForTesting(
- id: "job-1",
- cwd: "/tmp/project",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: entries
- )
- store.loadForTesting(
- serverState: .running,
- workspaces: [.init(cwd: "/tmp/project")],
- jobs: [job]
- )
-
- let read = try store.readReview(
- jobID: "job-1",
- logPage: .init(offset: 5, limit: 4)
- )
-
- #expect(read.logs.map(\.text) == ["line-5", "line-6", "line-7", "line-8"])
- #expect(read.logsPage == CodexReviewAPI.Log.Page(
- total: 12,
- offset: 5,
- limit: 4,
- returned: 4,
- hasMoreBefore: true,
- hasMoreAfter: true,
- previousOffset: 1,
- nextOffset: 9
- ))
- }
-
- @Test func readReviewRejectsInvalidLogPageRequests() throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
- )
- let job = CodexReviewJob.makeForTesting(
- id: "job-1",
- cwd: "/tmp/project",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running"
- )
- store.loadForTesting(
- serverState: .running,
- workspaces: [.init(cwd: "/tmp/project")],
- jobs: [job]
- )
-
- #expect(throws: (any Error).self) {
- try store.readReview(jobID: "job-1", logPage: .init(offset: -1))
- }
- #expect(throws: (any Error).self) {
- try store.readReview(jobID: "job-1", logPage: .init(limit: -1))
- }
- #expect(throws: (any Error).self) {
- try store.readReview(jobID: "job-1", logPage: .init(limit: CodexReviewAPI.Log.PageRequest.maxLimit + 1))
- }
- }
-
- @Test func readReviewProjectsGroupedLogEntriesBeforeFilteringAndPaging() throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
- )
- let job = CodexReviewJob.makeForTesting(
- id: "job-1",
- cwd: "/tmp/project",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(kind: .reasoningSummary, groupID: "reasoning-1", text: "first"),
- .init(kind: .reasoningSummary, groupID: "reasoning-1", text: " + second"),
- .init(
- kind: .plan,
- groupID: "plan-1",
- text: "- old",
- metadata: .init(sourceType: "plan", status: "inProgress")
- ),
- .init(kind: .plan, groupID: "plan-1", replacesGroup: true, text: "- new"),
- .init(kind: .command, groupID: "cmd-1", text: "$ swift test"),
- .init(kind: .commandOutput, groupID: "cmd-1", text: "output"),
- .init(kind: .agentMessage, text: "Done"),
- ]
- )
- store.loadForTesting(
- serverState: .running,
- workspaces: [.init(cwd: "/tmp/project")],
- jobs: [job]
- )
-
- let defaultRead = try store.readReview(jobID: "job-1")
- let allRead = try store.readReview(jobID: "job-1", logFilter: .all)
-
- #expect(defaultRead.logs.map(\.text) == [
- "first + second",
- "- new",
- "$ swift test",
- "Done",
- ])
- #expect(defaultRead.logs.allSatisfy { $0.replacesGroup == false })
- #expect(defaultRead.logs.first { $0.groupID == "plan-1" }?.metadata == nil)
- #expect(defaultRead.logsPage.total == 4)
- #expect(allRead.logs.map(\.text) == [
- "first + second",
- "- new",
- "$ swift test",
- "output",
- "Done",
- ])
- #expect(allRead.logsPage.total == 5)
- }
-
- @Test func readReviewFoldsReplacementOnlyGroupedKindsBeforePaging() throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
- )
- let job = CodexReviewJob.makeForTesting(
- id: "job-1",
- cwd: "/tmp/project",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(kind: .progress, groupID: "progress-1", replacesGroup: true, text: "Reviewing started"),
- .init(kind: .progress, groupID: "progress-1", replacesGroup: true, text: "Reviewing completed"),
- .init(kind: .toolCall, groupID: "tool-1", replacesGroup: true, text: "MCP tool started"),
- .init(kind: .toolCall, groupID: "tool-1", replacesGroup: true, text: "MCP tool completed"),
- .init(kind: .todoList, groupID: "turn-1", replacesGroup: true, text: "[inProgress] Inspect"),
- .init(kind: .todoList, groupID: "turn-1", replacesGroup: true, text: "[completed] Inspect"),
- .init(kind: .event, groupID: "turn-1", replacesGroup: true, text: "old diff"),
- .init(kind: .event, groupID: "turn-1", replacesGroup: true, text: "new diff"),
- .init(kind: .progress, groupID: "progress-2", text: "first progress"),
- .init(kind: .progress, groupID: "progress-2", text: "second progress"),
- .init(kind: .toolCall, groupID: "tool-2", replacesGroup: true, text: "Tool 2 started"),
- .init(kind: .toolCall, groupID: "tool-2", text: "Tool 2 progress"),
- .init(kind: .toolCall, groupID: "tool-2", replacesGroup: true, text: "Tool 2 completed"),
- ]
- )
- store.loadForTesting(
- serverState: .running,
- workspaces: [.init(cwd: "/tmp/project")],
- jobs: [job]
- )
-
- let read = try store.readReview(jobID: "job-1", logPage: .init(limit: 10))
-
- #expect(read.logs.map(\.text) == [
- "Reviewing completed",
- "MCP tool completed",
- "[completed] Inspect",
- "new diff",
- "first progress",
- "second progress",
- "Tool 2 completed",
- "Tool 2 progress",
- ])
- #expect(read.logs.allSatisfy { $0.replacesGroup == false })
- #expect(read.logsPage.total == 8)
- }
-
- @Test func reviewStartParsesFinalReviewFindings() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- )
- await backend.yield(.completed(summary: "Succeeded.", result: """
- Full review comments:
- - [P2] Add parser tests — Sources/Parser.swift:12-15
- The final review parser should be covered at the model layer.
- """))
- let read = try await result
-
- #expect(read.core.output.hasFinalReview)
- #expect(read.core.output.reviewResult?.state == .hasFindings)
- #expect(read.core.output.reviewResult?.findingCount == 1)
- #expect(read.core.output.reviewResult?.findings.first?.title == "[P2] Add parser tests")
- }
- }
-
- @Test func newlyStartedWorkspaceAppearsBeforeExistingWorkspaces() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let first = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/old-project", target: .baseBranch("main"))
- )
- await backend.yield(.completed(summary: "Succeeded.", result: "first"))
- _ = try await first
- await backend.finishEvents()
-
- async let second = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/new-project", target: .uncommittedChanges)
- )
- await backend.yield(.completed(summary: "Succeeded.", result: "second"))
- _ = try await second
-
- #expect(store.orderedWorkspaces.map(\.cwd) == ["/tmp/new-project", "/tmp/old-project"])
- }
- }
-
- @Test func newlyStartedWorkspaceUsesSortOrderAboveCurrentMaximum() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- store.loadForTesting(
- serverState: .running,
- workspaces: [.init(cwd: "/tmp/old-project")]
- )
- store.workspace(cwd: "/tmp/old-project")?.sortOrder = 10
-
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/new-project", target: .uncommittedChanges)
- )
- await backend.yield(.completed(summary: "Succeeded.", result: "new"))
- _ = try await result
-
- #expect(store.orderedWorkspaces.map(\.cwd) == ["/tmp/new-project", "/tmp/old-project"])
- }
- }
-
- @Test func newlyStartedReviewAppearsBeforeExistingJobsInWorkspace() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let first = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
- )
- await backend.yield(.completed(summary: "Succeeded.", result: "first"))
- _ = try await first
- await backend.finishEvents()
-
- async let second = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- )
- await backend.yield(.completed(summary: "Succeeded.", result: "second"))
- _ = try await second
-
- #expect(store.orderedJobs(inWorkspace: "/tmp/project").map(\.targetSummary) == [
- "Uncommitted changes",
- "Base branch: main",
- ])
- }
- }
-
- @Test func runningReviewElapsedSecondsUsesInjectedClock() async throws {
- let backend = FakeCodexReviewBackend()
- let clock = MutableTestClock(Date(timeIntervalSince1970: 1))
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- clock: .init(now: { clock.now() }),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- )
- try #require(await StoreSnapshotProbe(store: store).waitUntilJobStatus(.running, jobID: "job-1") != nil)
- clock.current = Date(timeIntervalSince1970: 13)
-
- #expect(try store.readReview(jobID: "job-1").elapsedSeconds == 12)
-
- await backend.yield(.completed(summary: "Succeeded.", result: "review text"))
- _ = try await result
- }
- }
-
- @Test func newlyStartedReviewUsesSortOrderAboveCurrentWorkspaceMaximum() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- let existing = CodexReviewJob.makeForTesting(
- id: "job-existing",
- cwd: "/tmp/project",
- targetSummary: "Existing",
- status: .succeeded,
- summary: "Done"
- )
- store.loadForTesting(
- serverState: .running,
- workspaces: [.init(cwd: "/tmp/project")],
- jobs: [existing]
- )
- store.job(id: "job-existing")?.sortOrder = 10
-
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- )
- await backend.yield(.completed(summary: "Succeeded.", result: "new"))
- _ = try await result
-
- #expect(store.orderedJobs(inWorkspace: "/tmp/project").map(\.targetSummary).first == "Uncommitted changes")
- }
- }
-
- @Test func workspaceReorderBeforeAnchorMovesBlockAndReportsMutation() {
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: FakeCodexReviewBackend())
- )
- let firstGroupedWorkspace = CodexReviewWorkspace(cwd: "/tmp/group-a-1")
- let secondWorkspace = CodexReviewWorkspace(cwd: "/tmp/workspace-b")
- let thirdWorkspace = CodexReviewWorkspace(cwd: "/tmp/workspace-c")
- let secondGroupedWorkspace = CodexReviewWorkspace(cwd: "/tmp/group-a-2")
- store.loadForTesting(
- serverState: .running,
- workspaces: [firstGroupedWorkspace, secondWorkspace, thirdWorkspace, secondGroupedWorkspace]
- )
-
- #expect(store.reorderWorkspaces(
- cwds: [firstGroupedWorkspace.cwd, secondGroupedWorkspace.cwd],
- beforeCWD: thirdWorkspace.cwd
- ))
- #expect(store.orderedWorkspaces.map(\.cwd) == [
- secondWorkspace.cwd,
- firstGroupedWorkspace.cwd,
- secondGroupedWorkspace.cwd,
- thirdWorkspace.cwd,
- ])
- #expect(store.reorderWorkspaces(cwds: [firstGroupedWorkspace.cwd], beforeCWD: firstGroupedWorkspace.cwd) == false)
- #expect(store.reorderWorkspaces(cwds: [firstGroupedWorkspace.cwd], beforeCWD: "/tmp/missing") == false)
- }
-
- @Test func jobReorderBeforeAnchorMovesItemAndReportsMutation() {
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: FakeCodexReviewBackend())
- )
- let workspace = CodexReviewWorkspace(cwd: "/tmp/project")
- let firstJob = CodexReviewJob.makeForTesting(
- id: "job-first",
- cwd: workspace.cwd,
- targetSummary: "First",
- status: .running,
- summary: "Running"
- )
- let secondJob = CodexReviewJob.makeForTesting(
- id: "job-second",
- cwd: workspace.cwd,
- targetSummary: "Second",
- status: .running,
- summary: "Running"
- )
- let thirdJob = CodexReviewJob.makeForTesting(
- id: "job-third",
- cwd: workspace.cwd,
- targetSummary: "Third",
- status: .running,
- summary: "Running"
- )
- store.loadForTesting(
- serverState: .running,
- workspaces: [workspace],
- jobs: [firstJob, secondJob, thirdJob]
- )
-
- #expect(store.reorderJob(id: firstJob.id, inWorkspace: workspace.cwd, beforeJobID: thirdJob.id))
- #expect(store.orderedJobs(in: workspace).map(\.id) == ["job-second", "job-first", "job-third"])
- #expect(store.reorderJob(id: firstJob.id, inWorkspace: workspace.cwd, beforeJobID: firstJob.id) == false)
- #expect(store.reorderJob(id: firstJob.id, inWorkspace: workspace.cwd, beforeJobID: "job-missing") == false)
- }
-
- @Test func cancelRunningReviewUsesBackendInterruptAndPublicState() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
- )
- try #require(await StoreSnapshotProbe(store: store).waitUntilJobStatus(.running, jobID: "job-1") != nil)
- let cancel = try await store.cancelReview(
- jobID: "job-1",
- cancellation: .mcpClient(message: "Stop")
- )
- await backend.yield(.cancelled("Stop"))
- _ = try await result
-
- #expect(cancel.cancelled)
- #expect(try store.readReview(jobID: "job-1").core.lifecycle.status == .cancelled)
- let commands = await backend.recordedCommands()
- #expect(commands.contains(.interruptReview(
- .init(threadID: "thread-1", turnID: "turn-1", reviewThreadID: "review-thread-1"),
- .init(message: "Stop")
- )))
- }
- }
-
- @Test func cancellationEnforcesLogLimitWithoutPostTerminalAppend() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- let initialText = String(repeating: "a", count: 250 * 1024)
- let delta = String(repeating: "b", count: 20 * 1024)
-
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
- )
- await backend.yield(.logEntry(
- kind: .rawReasoning,
- text: initialText,
- groupID: "reasoning-1",
- replacesGroup: false
- ))
- await backend.yield(.logEntry(
- kind: .rawReasoning,
- text: delta,
- groupID: "reasoning-1",
- replacesGroup: false
- ))
- #expect(await waitUntil {
- store.job(id: "job-1")?.logText.hasSuffix(delta) == true
- })
- _ = try await store.cancelReview(
- jobID: "job-1",
- cancellation: .mcpClient(message: "Stop")
- )
- await backend.yield(.cancelled("Stop"))
- let read = try await result
- let job = try #require(store.job(id: "job-1"))
-
- #expect(read.core.lifecycle.status == .cancelled)
- #expect(job.cappedLogBytes <= 256 * 1024)
- #expect(job.logText.hasSuffix(delta))
- #expect(job.lastLogMutation == .reload)
- }
- }
-
- @Test func transientNetworkOutageDoesNotRecoverReview() async throws {
- let backend = FakeCodexReviewBackend()
- let networkMonitor = ManualCodexReviewNetworkMonitor()
- let debounceGate = AsyncGate()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" }),
- networkMonitor: networkMonitor,
- networkRecoveryPolicy: .init(
- outageDebounce: .seconds(10),
- recoverySettle: .seconds(1),
- sleep: { _ in await debounceGate.wait() }
- )
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
- )
-
- networkMonitor.yield(.init(status: .unsatisfied))
- networkMonitor.yield(.satisfied())
- await debounceGate.open()
-
- let attemptedRecovery = await waitUntil(timeout: .milliseconds(100)) {
- let commands = await backend.recordedCommands()
- return commands.contains { command in
- if case .beginReviewRecovery = command {
- true
- } else {
- false
- }
- }
- }
- #expect(attemptedRecovery == false)
- let commands = await backend.recordedCommands()
- #expect(commands.contains { command in
- if case .beginReviewRecovery = command {
- true
- } else {
- false
- }
- } == false)
-
- await backend.yield(.completed(summary: "Succeeded.", result: "review text"))
- let read = try await result
- #expect(read.core.lifecycle.status == .succeeded)
- }
- }
-
- @Test func sustainedNetworkOutageInterruptsForRecoveryWithoutTerminalJob() async throws {
- let backend = FakeCodexReviewBackend()
- let networkMonitor = ManualCodexReviewNetworkMonitor()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" }),
- networkMonitor: networkMonitor,
- networkRecoveryPolicy: .init(sleep: { _ in })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
- )
-
- networkMonitor.yield(.init(status: .unsatisfied))
- try await backend.waitForBeginReviewRecovery(timeout: .seconds(2))
-
- let running = try store.readReview(jobID: "job-1")
- #expect(running.core.lifecycle.status == .running)
- #expect(running.core.output.summary == "Network unavailable; waiting to reconnect.")
- _ = try await store.cancelReview(jobID: "job-1", cancellation: .mcpClient(message: "Stop"))
- await backend.yield(.cancelled("Stop"))
- _ = try await result
- }
- }
-
- @Test func networkRecoveryWaitDiscardsOldAttemptCompletion() async throws {
- let initialRun = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-1",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
- let recoveredRun = CodexReviewBackendModel.Review.Run(
- attemptID: "attempt-recovered",
- threadID: "thread-1",
- turnID: "turn-2",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
- let backend = FakeCodexReviewBackend(nextRun: initialRun)
- await backend.setNextRecoveredRun(recoveredRun)
- let networkMonitor = ManualCodexReviewNetworkMonitor()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" }),
- networkMonitor: networkMonitor,
- networkRecoveryPolicy: .init(sleep: { _ in })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let running = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main")),
- waitTimeout: .milliseconds(20)
- )
-
- networkMonitor.yield(.init(status: .unsatisfied))
- try await backend.waitForBeginReviewRecovery(timeout: .seconds(2))
- _ = try await running
-
- await backend.yield(.message("completed review text"), for: initialRun)
- await backend.yield(.completed(summary: "Succeeded.", result: nil), for: initialRun)
- networkMonitor.yield(.satisfied())
- try await backend.waitForResumeReviewRecovery(timeout: .seconds(2))
- try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun))
- await backend.yield(.completed(summary: "Succeeded.", result: "recovered review"), for: recoveredRun)
- let final = try await store.awaitReview(sessionID: "session-1", jobID: "job-1", timeout: .seconds(1))
-
- #expect(final.core.lifecycle.status == .succeeded)
- #expect(final.core.run.turnID == "turn-2")
- #expect(final.core.output.lastAgentMessage == "recovered review")
- let commands = await backend.recordedCommands()
- #expect(commands.contains { command in
- if case .resumeReviewRecovery = command {
- true
- } else {
- false
- }
- })
- let logText = try store.readReview(jobID: "job-1").logs.map(\.text).joined(separator: "\n")
- #expect(logText.contains("completed review text") == false)
- }
- }
-
- @Test func networkRecoveryDiscardsOldAttemptEventsDuringRecoverySettle() async throws {
- let initialRun = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-1",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
- let recoveredRun = CodexReviewBackendModel.Review.Run(
- attemptID: "attempt-recovered",
- threadID: "thread-1",
- turnID: "turn-2",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
- let backend = FakeCodexReviewBackend(nextRun: initialRun)
- await backend.setNextRecoveredRun(recoveredRun)
- let networkMonitor = ManualCodexReviewNetworkMonitor()
- let settleGate = AsyncGate()
- let sleeper = ControlledTestSleeper(gate: settleGate)
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" }),
- networkMonitor: networkMonitor,
- networkRecoveryPolicy: .init(
- outageDebounce: .seconds(10),
- recoverySettle: .seconds(1),
- sleep: { _ in await sleeper.sleep() }
- )
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
- )
-
- networkMonitor.yield(.init(status: .unsatisfied))
- try await backend.waitForBeginReviewRecovery(timeout: .seconds(2))
- await sleeper.blockFutureSleeps()
- networkMonitor.yield(.satisfied())
- #expect(await waitUntil {
- store.job(id: "job-1")?.core.output.summary == "Network restored; restarting review."
- })
-
- await backend.yield(.message("completed during settle"), for: initialRun)
- await backend.yield(.completed(summary: "Succeeded.", result: nil), for: initialRun)
- await settleGate.open()
- try await backend.waitForResumeReviewRecovery(timeout: .seconds(2))
- try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun))
- await backend.yield(.completed(summary: "Succeeded.", result: "recovered review"), for: recoveredRun)
- let read = try await result
-
- #expect(read.core.lifecycle.status == .succeeded)
- #expect(read.core.run.turnID == "turn-2")
- #expect(read.core.output.lastAgentMessage == "recovered review")
- let commands = await backend.recordedCommands()
- #expect(commands.contains { command in
- if case .resumeReviewRecovery = command {
- true
- } else {
- false
- }
- })
- let logText = try store.readReview(jobID: "job-1").logs.map(\.text).joined(separator: "\n")
- #expect(logText.contains("completed during settle") == false)
- }
- }
-
- @Test func networkRecoveryRepeatedSatisfiedSnapshotsRestartAfterLatestSettle() async throws {
- let initialRun = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-1",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
- let recoveredRun = CodexReviewBackendModel.Review.Run(
- attemptID: "attempt-recovered",
- threadID: "thread-1",
- turnID: "turn-2",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
- let backend = FakeCodexReviewBackend(nextRun: initialRun)
- await backend.setNextRecoveredRun(recoveredRun)
- let networkMonitor = ManualCodexReviewNetworkMonitor()
- let settleGate = AsyncGate()
- let sleeper = ControlledTestSleeper(gate: settleGate)
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" }),
- networkMonitor: networkMonitor,
- networkRecoveryPolicy: .init(
- outageDebounce: .seconds(10),
- recoverySettle: .seconds(1),
- sleep: { _ in await sleeper.sleep() }
- )
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
- )
-
- networkMonitor.yield(.init(status: .unsatisfied))
- try await backend.waitForBeginReviewRecovery(timeout: .seconds(2))
- await sleeper.blockFutureSleeps()
- networkMonitor.yield(.satisfied())
- #expect(await waitUntil {
- store.job(id: "job-1")?.core.output.summary == "Network restored; restarting review."
- })
- networkMonitor.yield(.satisfied())
- await settleGate.open()
- try await backend.waitForResumeReviewRecovery(timeout: .seconds(2))
- try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun))
-
- await backend.yield(.completed(summary: "Succeeded.", result: "recovered review"), for: recoveredRun)
- let read = try await result
-
- #expect(read.core.lifecycle.status == .succeeded)
- #expect(read.core.run.turnID == "turn-2")
- #expect(read.core.output.lastAgentMessage == "recovered review")
- }
- }
-
- @Test func networkRecoveryClosesActiveCommandsAsCanceled() async throws {
- let initialRun = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-1",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
- let recoveredRun = CodexReviewBackendModel.Review.Run(
- attemptID: "attempt-recovered",
- threadID: "thread-1",
- turnID: "turn-2",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
- let backend = FakeCodexReviewBackend(nextRun: initialRun)
- await backend.setNextRecoveredRun(recoveredRun)
- let networkMonitor = ManualCodexReviewNetworkMonitor()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" }),
- networkMonitor: networkMonitor,
- networkRecoveryPolicy: .init(sleep: { _ in })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let running = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main")),
- waitTimeout: .milliseconds(20)
- )
- await backend.yield(.logEntry(
- kind: .command,
- text: "$ git diff",
- groupID: "cmd-1",
- replacesGroup: true,
- metadata: .init(
- sourceType: "commandExecution",
- status: "inProgress",
- itemID: "cmd-1",
- command: "git diff",
- startedAt: Date(timeIntervalSince1970: 1),
- commandStatus: "inProgress"
- )
- ), for: initialRun)
-
- networkMonitor.yield(.init(status: .unsatisfied))
- try await backend.waitForBeginReviewRecovery(timeout: .seconds(2))
- _ = try await running
- networkMonitor.yield(.satisfied())
- try await backend.waitForResumeReviewRecovery(timeout: .seconds(2))
- try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun))
- await backend.yield(.completed(summary: "Succeeded.", result: "recovered review"), for: recoveredRun)
-
- let final = try await store.awaitReview(sessionID: "session-1", jobID: "job-1", timeout: .seconds(1))
- let commandLogs = try #require(store.job(id: "job-1"))
- .logEntries
- .filter { $0.kind == .command && $0.groupID == "cmd-1" }
- let closed = try #require(commandLogs.last)
-
- #expect(final.core.lifecycle.status == .succeeded)
- #expect(commandLogs.count == 2)
- #expect(closed.metadata?.status == "canceled")
- #expect(closed.metadata?.commandStatus == "canceled")
- }
- }
-
- @Test func networkRecoveryUsesActualStartedTurn() async throws {
- let initialRun = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-response",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
- let recoveredRun = CodexReviewBackendModel.Review.Run(
- attemptID: "attempt-recovered",
- threadID: "thread-1",
- turnID: "turn-recovered",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
- let backend = FakeCodexReviewBackend(nextRun: initialRun)
- await backend.setNextRecoveredRun(recoveredRun)
- let networkMonitor = ManualCodexReviewNetworkMonitor()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" }),
- networkMonitor: networkMonitor,
- networkRecoveryPolicy: .init(sleep: { _ in })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
- )
-
- await backend.yield(.started(
- turnID: "turn-actual",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- ), for: initialRun)
- #expect(await waitUntil {
- store.job(id: "job-1")?.core.run.turnID == "turn-actual"
- })
-
- networkMonitor.yield(.init(status: .unsatisfied))
- try await backend.waitForBeginReviewRecovery(timeout: .seconds(2))
- let commandsAfterInterrupt = await backend.recordedCommands()
- let interruptedRuns = commandsAfterInterrupt.compactMap { command -> CodexReviewBackendModel.Review.Run? in
- if case .beginReviewRecovery(let run, _) = command {
- return run
- }
- return nil
- }
- #expect(interruptedRuns.last?.turnID == "turn-actual")
-
- networkMonitor.yield(.satisfied())
- try await backend.waitForResumeReviewRecovery(timeout: .seconds(2))
- let commandsAfterRecovery = await backend.recordedCommands()
- let recoveredFromRuns = commandsAfterRecovery.compactMap { command -> CodexReviewBackendModel.Review.Run? in
- if case .resumeReviewRecovery(let token, _) = command {
- return token.interruptedRun
- }
- return nil
- }
- #expect(recoveredFromRuns.last?.turnID == "turn-actual")
-
- try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun))
- await backend.yield(.completed(summary: "Succeeded.", result: "recovered review"), for: recoveredRun)
- let read = try await result
-
- #expect(read.core.lifecycle.status == .succeeded)
- #expect(read.core.run.turnID == "turn-recovered")
- }
- }
-
- @Test func networkRecoveryRestartsReviewOnSameJobAndSucceeds() async throws {
- let initialRun = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-1",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
- let recoveredRun = CodexReviewBackendModel.Review.Run(
- attemptID: "attempt-recovered",
- threadID: "thread-1",
- turnID: "turn-2",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
- let backend = FakeCodexReviewBackend(nextRun: initialRun)
- await backend.setNextRecoveredRun(recoveredRun)
- let networkMonitor = ManualCodexReviewNetworkMonitor()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" }),
- networkMonitor: networkMonitor,
- networkRecoveryPolicy: .init(sleep: { _ in })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
- )
-
- networkMonitor.yield(.init(status: .unsatisfied))
- try await backend.waitForBeginReviewRecovery(timeout: .seconds(2))
- await backend.yield(.message("stale aborted output"), for: initialRun)
- await backend.yield(.cancelled("Network lost"), for: initialRun)
- networkMonitor.yield(.satisfied())
- try await backend.waitForResumeReviewRecovery(timeout: .seconds(2))
- #expect(await waitUntil {
- guard let read = try? store.readReview(jobID: "job-1") else {
- return false
- }
- return read.core.run.turnID == "turn-2"
- })
- try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun))
-
- await backend.yield(.completed(summary: "Succeeded.", result: "recovered review"), for: recoveredRun)
- let read = try await result
-
- #expect(read.core.lifecycle.status == .succeeded)
- #expect(read.core.run.turnID == "turn-2")
- #expect(read.core.run.threadID == "thread-1")
- #expect(read.core.output.lastAgentMessage == "recovered review")
- let logText = try store.readReview(jobID: "job-1").logs.map(\.text).joined(separator: "\n")
- #expect(logText.contains("Network unavailable; waiting to reconnect."))
- #expect(logText.contains("Network restored; restarting review."))
- #expect(logText.contains("stale aborted output") == false)
- }
- }
-
- @Test func networkRecoveryClearsAbandonedAttemptOutputBeforeRecoveredCompletion() async throws {
- let initialRun = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-1",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
- let recoveredRun = CodexReviewBackendModel.Review.Run(
- attemptID: "attempt-recovered",
- threadID: "thread-1",
- turnID: "turn-2",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
- let backend = FakeCodexReviewBackend(nextRun: initialRun)
- await backend.setNextRecoveredRun(recoveredRun)
- let networkMonitor = ManualCodexReviewNetworkMonitor()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" }),
- networkMonitor: networkMonitor,
- networkRecoveryPolicy: .init(sleep: { _ in })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
- )
- try #require(await StoreSnapshotProbe(store: store).waitUntilJobStatus(.running, jobID: "job-1") != nil)
-
- await backend.yield(.messageDelta("stale ", itemID: "message-1"), for: initialRun)
- await backend.yield(.messageDelta("output", itemID: "message-1"), for: initialRun)
- try #require(await StoreSnapshotProbe(store: store).waitUntil(timeout: .seconds(2)) {
- $0.job("job-1")?.lastAgentMessage == "stale output"
- } != nil)
-
- networkMonitor.yield(.init(status: .unsatisfied))
- try await backend.waitForBeginReviewRecovery(timeout: .seconds(2))
- networkMonitor.yield(.satisfied())
- try await backend.waitForResumeReviewRecovery(timeout: .seconds(2))
- try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun))
-
- await backend.yield(.messageDelta("fresh review", itemID: "message-1"), for: recoveredRun)
- await backend.yield(.completed(summary: "Succeeded.", result: nil), for: recoveredRun)
- let read = try await result
-
- #expect(read.core.lifecycle.status == .succeeded)
- #expect(read.core.run.turnID == "turn-2")
- #expect(read.core.output.lastAgentMessage == "fresh review")
- #expect(read.core.output.hasFinalReview)
- let logText = try store.readReview(jobID: "job-1").logs.map(\.text).joined(separator: "\n")
- #expect(logText.contains("stale output") == false)
- #expect(logText.contains("fresh review"))
- }
- }
-
- @Test func networkRecoveryIgnoresStaleCompletionAfterRecoveredSubscriptionStarts() async throws {
- let initialRun = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-1",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
- let recoveredRun = CodexReviewBackendModel.Review.Run(
- attemptID: "attempt-recovered",
- threadID: "thread-1",
- turnID: "turn-2",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
- let backend = FakeCodexReviewBackend(nextRun: initialRun)
- await backend.setNextRecoveredRun(recoveredRun)
- let networkMonitor = ManualCodexReviewNetworkMonitor()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" }),
- networkMonitor: networkMonitor,
- networkRecoveryPolicy: .init(sleep: { _ in })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
- )
-
- networkMonitor.yield(.init(status: .unsatisfied))
- try await backend.waitForBeginReviewRecovery(timeout: .seconds(2))
- networkMonitor.yield(.satisfied())
- try await backend.waitForResumeReviewRecovery(timeout: .seconds(2))
- try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun))
-
- await backend.yield(.completed(summary: "Succeeded.", result: "stale review"), for: initialRun)
- await backend.yield(.completed(summary: "Succeeded.", result: "recovered review"), for: recoveredRun)
-
- let read = try await result
- #expect(read.core.lifecycle.status == .succeeded)
- #expect(read.core.run.turnID == "turn-2")
- #expect(read.core.output.lastAgentMessage == "recovered review")
- }
- }
-
- @Test func networkRecoveryIgnoresStaleTerminalQueuedWhileRestarting() async throws {
- let initialRun = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-1",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
- let recoveredRun = CodexReviewBackendModel.Review.Run(
- attemptID: "attempt-recovered",
- threadID: "thread-1",
- turnID: "turn-2",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
- let backend = FakeCodexReviewBackend(nextRun: initialRun)
- await backend.setNextRecoveredRun(recoveredRun)
- let recoverGate = AsyncGate()
- await backend.holdResumeReviewRecovery(with: recoverGate)
- let networkMonitor = ManualCodexReviewNetworkMonitor()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" }),
- networkMonitor: networkMonitor,
- networkRecoveryPolicy: .init(sleep: { _ in })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
- )
-
- networkMonitor.yield(.init(status: .unsatisfied))
- try await backend.waitForBeginReviewRecovery(timeout: .seconds(2))
- networkMonitor.yield(.satisfied())
- try await backend.waitForResumeReviewRecovery(timeout: .seconds(2))
- await backend.yield(.cancelled("Network lost"), for: initialRun)
- await recoverGate.open()
- try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun))
-
- await backend.yield(.completed(summary: "Succeeded.", result: "recovered review"), for: recoveredRun)
- let read = try await result
-
- #expect(read.core.lifecycle.status == .succeeded)
- #expect(read.core.run.turnID == "turn-2")
- #expect(read.core.output.lastAgentMessage == "recovered review")
- }
- }
-
- @Test func networkRecoveryResubscribesWhenInterruptedEventStreamFinished() async throws {
- let initialRun = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-1",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
- let recoveredRun = CodexReviewBackendModel.Review.Run(
- attemptID: "attempt-recovered",
- threadID: "thread-1",
- turnID: "turn-2",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
- let backend = FakeCodexReviewBackend(nextRun: initialRun)
- await backend.setNextRecoveredRun(recoveredRun)
- let networkMonitor = ManualCodexReviewNetworkMonitor()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" }),
- networkMonitor: networkMonitor,
- networkRecoveryPolicy: .init(sleep: { _ in })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
- )
-
- networkMonitor.yield(.init(status: .unsatisfied))
- try await backend.waitForBeginReviewRecovery(timeout: .seconds(2))
- await backend.finishEvents(for: initialRun)
- networkMonitor.yield(.satisfied())
- try await backend.waitForResumeReviewRecovery(timeout: .seconds(2))
- try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun))
-
- await backend.yield(.completed(summary: "Succeeded.", result: "recovered review"), for: recoveredRun)
- let read = try await result
-
- #expect(read.core.lifecycle.status == .succeeded)
- #expect(read.core.run.turnID == "turn-2")
- #expect(read.core.output.lastAgentMessage == "recovered review")
- }
- }
-
- @Test func cancellationWhileRecoveryRestartIsInFlightStopsRecoveredRun() async throws {
- let initialRun = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-1",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
- let recoveredRun = CodexReviewBackendModel.Review.Run(
- attemptID: "attempt-recovered",
- threadID: "thread-1",
- turnID: "turn-2",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
- let backend = FakeCodexReviewBackend(nextRun: initialRun)
- await backend.setNextRecoveredRun(recoveredRun)
- let recoverGate = AsyncGate()
- await backend.holdResumeReviewRecovery(with: recoverGate)
- let networkMonitor = ManualCodexReviewNetworkMonitor()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" }),
- networkMonitor: networkMonitor,
- networkRecoveryPolicy: .init(sleep: { _ in })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
- )
-
- networkMonitor.yield(.init(status: .unsatisfied))
- try await backend.waitForBeginReviewRecovery(timeout: .seconds(2))
- networkMonitor.yield(.satisfied())
- try await backend.waitForResumeReviewRecovery(timeout: .seconds(2))
-
- let cancel = try await store.cancelReview(jobID: "job-1", cancellation: .mcpClient(message: "Stop"))
- #expect(cancel.cancelled)
- await recoverGate.open()
-
- let read = try await result
- #expect(read.core.lifecycle.status == .cancelled)
- #expect(read.core.run.turnID == "turn-1")
-
- let commands = await backend.recordedCommands()
- #expect(commands.contains(.interruptReview(
- initialRun,
- .init(message: "Stop")
- )) == false)
- #expect(commands.contains(.interruptReview(
- recoveredRun,
- .init(message: "Stop")
- )))
- #expect(commands.contains(.cleanupReview(recoveredRun)))
- }
- }
-
- @Test func cancellationAfterRecoveryEventStreamFinishesWakesWorker() async throws {
- let initialRun = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-1",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
- let backend = FakeCodexReviewBackend(nextRun: initialRun)
- let networkMonitor = ManualCodexReviewNetworkMonitor()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" }),
- networkMonitor: networkMonitor,
- networkRecoveryPolicy: .init(sleep: { _ in })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let running = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main")),
- waitTimeout: .milliseconds(20)
- )
-
- networkMonitor.yield(.init(status: .unsatisfied))
- try await backend.waitForBeginReviewRecovery(timeout: .seconds(2))
- _ = try await running
- await backend.finishEvents(for: initialRun)
-
- let cancel = try await store.cancelReview(jobID: "job-1", cancellation: .mcpClient(message: "Stop"))
- let cleanedUp = await waitUntil {
- store.reviewWorkerTasks["job-1"] == nil && store.activeRuns["job-1"] == nil
- }
- let read = try store.readReview(jobID: "job-1")
-
- #expect(cancel.cancelled)
- #expect(cleanedUp)
- #expect(read.core.lifecycle.status == .cancelled)
- #expect(read.core.lifecycle.cancellation?.message == "Stop")
- }
- }
-
- @Test func runtimeStopLocalCancellationDetachesWorker() async throws {
- let run = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-1",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
- let backend = FakeCodexReviewBackend(nextRun: run)
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let running = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main")),
- waitTimeout: .milliseconds(20)
- )
- _ = try await running
-
- let locallyCancelledJobIDs = store.cancelActiveReviewsLocallyForRuntimeStop(
- reason: .system(message: "Review runtime stopped."),
- cancelWorkers: false
- )
- let cancelled = try store.readReview(jobID: "job-1")
-
- #expect(locallyCancelledJobIDs == ["job-1"])
- #expect(cancelled.core.lifecycle.status == .cancelled)
- #expect(store.reviewWorkerTasks["job-1"] != nil)
- #expect(store.activeRuns["job-1"] == run)
-
- store.cancelAndDetachReviewWorkersForRuntimeStop(jobIDs: locallyCancelledJobIDs)
-
- #expect(store.reviewWorkerTasks["job-1"] == nil)
- #expect(store.activeRuns["job-1"] == nil)
- }
- }
-
- @Test func stopInterruptsActiveReviewBeforeMarkingJobStopped() async throws {
- let run = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-1",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
- let backend = FakeCodexReviewBackend(nextRun: run)
- let interruptGate = AsyncGate()
- await backend.holdInterruptReview(with: interruptGate)
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- await store.start()
- async let running = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main")),
- waitTimeout: .milliseconds(20)
- )
- _ = try await running
-
- let stopTask = Task { @MainActor in
- await store.stop()
- }
- try await backend.waitForInterruptReview(timeout: .seconds(2))
- let inFlight = try store.readReview(jobID: "job-1")
-
- #expect(inFlight.core.lifecycle.status == .running)
- await interruptGate.open()
- await stopTask.value
-
- let stopped = try store.readReview(jobID: "job-1")
- let commands = await backend.recordedCommands()
- #expect(commands.contains(.interruptReview(run, .init(message: "Review runtime stopped."))))
- #expect(stopped.core.lifecycle.status == .cancelled)
- #expect(store.activeRuns["job-1"] == nil)
- #expect(store.reviewWorkerTasks["job-1"] == nil)
- }
- }
-
- @Test func runtimeStopDetachesNetworkRecoveryWaitingWorker() async throws {
- let run = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-1",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
- let backend = FakeCodexReviewBackend(nextRun: run)
- let networkMonitor = ManualCodexReviewNetworkMonitor()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" }),
- networkMonitor: networkMonitor,
- networkRecoveryPolicy: .init(sleep: { _ in })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let running = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main")),
- waitTimeout: .milliseconds(20)
- )
-
- networkMonitor.yield(.init(status: .unsatisfied))
- try await backend.waitForBeginReviewRecovery(timeout: .seconds(2))
- _ = try await running
-
- let locallyCancelledJobIDs = store.cancelActiveReviewsLocallyForRuntimeStop(
- reason: .system(message: "Review runtime stopped."),
- cancelWorkers: false
- )
- store.cancelAndDetachReviewWorkersForRuntimeStop(jobIDs: locallyCancelledJobIDs)
-
- #expect(store.reviewWorkerTasks["job-1"] == nil)
- #expect(store.activeRuns["job-1"] == nil)
- #expect(store.reviewRecoveryWaitingJobIDs.contains("job-1") == false)
- }
- }
-
- @Test func runtimeStopCanDrainDetachedWorkerCleanup() async throws {
- let run = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-1",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
- let backend = FakeCodexReviewBackend(nextRun: run)
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let running = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main")),
- waitTimeout: .milliseconds(20)
- )
- _ = try await running
-
- let locallyCancelledJobIDs = store.cancelActiveReviewsLocallyForRuntimeStop(
- reason: .system(message: "Review runtime stopped."),
- cancelWorkers: false
- )
- store.cancelAndDetachReviewWorkersForRuntimeStop(jobIDs: locallyCancelledJobIDs)
-
- #expect(await store.drainRuntimeStopDetachedReviewWorkers(timeout: .seconds(2)))
- #expect(store.runtimeStopDetachedReviewWorkerTasks["job-1"] == nil)
- #expect(await backend.recordedCommands().contains(.cleanupReview(run)))
- }
- }
-
- @Test func runtimeStopDetachLetsStartReviewReturnWhenBackendStartIsStuck() async throws {
- let backend = FakeCodexReviewBackend()
- let startReviewGate = AsyncGate()
- await backend.holdStartReview(with: startReviewGate)
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- let running = Task { @MainActor in
- try await store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
- )
- }
- try await backend.waitForStartReview(timeout: .seconds(2))
-
- let locallyCancelledJobIDs = store.cancelActiveReviewsLocallyForRuntimeStop(
- reason: .system(message: "Review runtime stopped."),
- cancelWorkers: false
- )
- store.cancelAndDetachReviewWorkersForRuntimeStop(jobIDs: locallyCancelledJobIDs)
- let resultBeforeStartReviewUnblocked = try await waitForTaskValue(running, timeout: .seconds(1))
- await startReviewGate.open()
- let result = try #require(resultBeforeStartReviewUnblocked)
-
- #expect(locallyCancelledJobIDs == ["job-1"])
- #expect(result.core.lifecycle.status == .cancelled)
- #expect(store.reviewWorkerTasks["job-1"] == nil)
- #expect(store.activeRuns["job-1"] == nil)
- }
- }
-
- @Test func cancellationDuringNetworkRecoveryStopsWhenEventStreamFinishes() async throws {
- let initialRun = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-1",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
- let backend = FakeCodexReviewBackend(nextRun: initialRun)
- let networkMonitor = ManualCodexReviewNetworkMonitor()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" }),
- networkMonitor: networkMonitor,
- networkRecoveryPolicy: .init(sleep: { _ in })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
- )
-
- networkMonitor.yield(.init(status: .unsatisfied))
- try await backend.waitForBeginReviewRecovery(timeout: .seconds(2))
- _ = try await store.cancelReview(jobID: "job-1", cancellation: .mcpClient(message: "Stop"))
- await backend.finishEvents(for: initialRun)
-
- let read = try await result
- #expect(read.core.lifecycle.status == .cancelled)
- #expect(read.core.lifecycle.cancellation?.message == "Stop")
- }
- }
-
- @Test func networkRecoveryIgnoresOldAttemptEventsAfterRecoveryBegins() async throws {
- let initialRun = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-1",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
- let recoveredRun = CodexReviewBackendModel.Review.Run(
- attemptID: "attempt-recovered",
- threadID: "thread-1",
- turnID: "turn-2",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
- let backend = FakeCodexReviewBackend(nextRun: initialRun)
- await backend.setNextRecoveredRun(recoveredRun)
- let networkMonitor = ManualCodexReviewNetworkMonitor()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" }),
- networkMonitor: networkMonitor,
- networkRecoveryPolicy: .init(sleep: { _ in })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
- )
-
- networkMonitor.yield(.init(status: .unsatisfied))
- try await backend.waitForBeginReviewRecovery(timeout: .seconds(2))
- await backend.yield(.message("stale old attempt output"), for: initialRun)
- await backend.yield(.completed(summary: "Succeeded.", result: nil), for: initialRun)
-
- networkMonitor.yield(.satisfied())
- try await backend.waitForResumeReviewRecovery(timeout: .seconds(2))
- try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun))
- await backend.yield(.completed(summary: "Succeeded.", result: "recovered review"), for: recoveredRun)
- let read = try await result
-
- #expect(read.core.lifecycle.status == .succeeded)
- #expect(read.core.run.turnID == "turn-2")
- #expect(read.core.output.lastAgentMessage == "recovered review")
- let logText = try store.readReview(jobID: "job-1").logs.map(\.text).joined(separator: "\n")
- #expect(logText.contains("stale old attempt output") == false)
- }
- }
-
- @Test func userCancellationWinsOverPendingNetworkRecovery() async throws {
- let backend = FakeCodexReviewBackend()
- let networkMonitor = ManualCodexReviewNetworkMonitor()
- let debounceGate = AsyncGate()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" }),
- networkMonitor: networkMonitor,
- networkRecoveryPolicy: .init(sleep: { _ in await debounceGate.wait() })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
- )
- try #require(await StoreSnapshotProbe(store: store).waitUntilJobStatus(.running, jobID: "job-1") != nil)
-
- networkMonitor.yield(.init(status: .unsatisfied))
- _ = try await store.cancelReview(jobID: "job-1", cancellation: .mcpClient(message: "Stop"))
- await debounceGate.open()
- await backend.yield(.cancelled("Stop"))
- let read = try await result
-
- #expect(read.core.lifecycle.status == .cancelled)
- let commands = await backend.recordedCommands()
- #expect(commands.contains { command in
- if case .beginReviewRecovery = command {
- true
- } else {
- false
- }
- } == false)
- #expect(commands.contains { command in
- if case .resumeReviewRecovery = command {
- true
- } else {
- false
- }
- } == false)
- }
- }
-
- @Test func recoveryFailureFailsReviewAndLogsError() async throws {
- let backend = FakeCodexReviewBackend()
- await backend.failRecovery(message: "Rollback failed")
- let networkMonitor = ManualCodexReviewNetworkMonitor()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" }),
- networkMonitor: networkMonitor,
- networkRecoveryPolicy: .init(sleep: { _ in })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
- )
-
- networkMonitor.yield(.init(status: .requiresConnection))
- try await backend.waitForBeginReviewRecovery(timeout: .seconds(2))
- networkMonitor.yield(.satisfied())
- let read = try await result
-
- #expect(read.core.lifecycle.status == .failed)
- #expect(read.core.lifecycle.errorMessage == "Rollback failed")
- #expect(read.logs.contains { $0.kind == .error && $0.text == "Rollback failed" })
- }
- }
-
- @Test func cancelRunningReviewClosesActiveCommandLog() async throws {
- let backend = FakeCodexReviewBackend()
- let completedAt = Date(timeIntervalSince1970: 10)
- let startedAt = Date(timeIntervalSince1970: 6)
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- clock: .init(now: { completedAt })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- let running = CodexReviewJob.makeForTesting(
- id: "job-1",
- cwd: "/tmp/project",
- targetSummary: "Uncommitted changes",
- threadID: "thread-1",
- turnID: "turn-1",
- status: .running,
- startedAt: startedAt,
- summary: "Running",
- logEntries: [
- .init(
- kind: .command,
- groupID: "cmd-1",
- replacesGroup: true,
- text: "$ git diff",
- metadata: .init(
- sourceType: "commandExecution",
- status: "inProgress",
- itemID: "cmd-1",
- command: "git diff",
- startedAt: startedAt,
- commandStatus: "inProgress"
- ),
- timestamp: startedAt
- )
- ]
- )
- store.loadForTesting(
- serverState: .running,
- workspaces: [.init(cwd: "/tmp/project")],
- jobs: [running]
- )
-
- let cancel = try await store.cancelReview(
- jobID: "job-1",
- cancellation: .mcpClient(message: "Stop")
- )
- let read = try store.readReview(jobID: "job-1", logFilter: .all)
- let commandLogs = try #require(store.job(id: "job-1"))
- .logEntries
- .filter { $0.kind == .command && $0.groupID == "cmd-1" }
- let closed = try #require(commandLogs.last)
-
- #expect(cancel.cancelled)
- #expect(read.core.lifecycle.status == .cancelled)
- #expect(commandLogs.count == 2)
- #expect(closed.replacesGroup)
- #expect(closed.metadata?.status == "canceled")
- #expect(closed.metadata?.commandStatus == "canceled")
- #expect(closed.metadata?.command == "git diff")
- #expect(closed.metadata?.startedAt == startedAt)
- #expect(closed.metadata?.completedAt == completedAt)
- #expect(closed.metadata?.durationMs == 4_000)
- }
- }
-
- @Test func sessionScopedCancelRejectsJobFromDifferentSession() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
- )
-
- await #expect(throws: (any Error).self) {
- try await store.cancelReview(
- jobID: "job-1",
- sessionID: "session-2",
- cancellation: .mcpClient(message: "Stop")
- )
- }
- #expect(try store.readReview(jobID: "job-1").cancellable)
-
- await backend.yield(.completed(summary: "Succeeded.", result: "review text"))
- _ = try await result
-
- let commands = await backend.recordedCommands()
- #expect(commands.contains {
- if case .interruptReview = $0 {
- return true
- }
- return false
- } == false)
- }
- }
-
- @Test func cancelledReviewStaysCancelledWhenStreamClosesWithError() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
- )
- try #require(await StoreSnapshotProbe(store: store).waitUntilJobStatus(.running, jobID: "job-1") != nil)
- _ = try await store.cancelReview(
- jobID: "job-1",
- cancellation: .mcpClient(message: "Stop")
- )
- await backend.finishEvents(throwing: StreamClosedError())
- let read = try await result
-
- #expect(read.core.lifecycle.status == .cancelled)
- #expect(read.core.output.summary == "Stop")
- }
- }
-
- @Test func failedReviewPreservesBufferedEventsBeforeStreamError() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
- )
- try #require(await StoreSnapshotProbe(store: store).waitUntilJobStatus(.running, jobID: "job-1") != nil)
- await backend.yield(.message("partial review"))
- await backend.finishEvents(throwing: StreamClosedError())
- let read = try await result
-
- #expect(read.core.lifecycle.status == .failed)
- #expect(read.core.output.lastAgentMessage == "partial review")
- #expect(read.logs.map(\.text).contains("partial review"))
- }
- }
-
- @Test func pendingNetworkOutageDefersStreamFailureUntilRecovery() async throws {
- let initialRun = CodexReviewBackendModel.Review.Run(
- threadID: "thread-1",
- turnID: "turn-1",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
- let recoveredRun = CodexReviewBackendModel.Review.Run(
- attemptID: "attempt-recovered",
- threadID: "thread-1",
- turnID: "turn-2",
- reviewThreadID: "review-thread-1",
- model: "gpt-5"
- )
- let backend = FakeCodexReviewBackend(nextRun: initialRun)
- await backend.setNextRecoveredRun(recoveredRun)
- let networkMonitor = ManualCodexReviewNetworkMonitor()
- let outageSleepStarted = AsyncGate()
- let debounceGate = AsyncGate()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" }),
- networkMonitor: networkMonitor,
- networkRecoveryPolicy: .init(
- outageDebounce: .seconds(10),
- recoverySettle: .seconds(1),
- sleep: { _ in
- await outageSleepStarted.open()
- await debounceGate.wait()
- }
- )
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
- )
- try #require(await StoreSnapshotProbe(store: store).waitUntilJobStatus(.running, jobID: "job-1") != nil)
-
- networkMonitor.yield(.init(status: .unsatisfied))
- await outageSleepStarted.wait()
- await backend.finishEvents(throwing: StreamClosedError(), for: initialRun)
-
- let failedBeforeOutageConfirmed = await StoreSnapshotProbe(store: store)
- .waitUntilJobStatus(.failed, jobID: "job-1", timeout: .milliseconds(100)) != nil
- #expect(failedBeforeOutageConfirmed == false)
-
- await debounceGate.open()
- try await backend.waitForBeginReviewRecovery(timeout: .seconds(2))
- networkMonitor.yield(.satisfied())
- try await backend.waitForResumeReviewRecovery(timeout: .seconds(2))
- try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun))
-
- await backend.yield(.completed(summary: "Succeeded.", result: "recovered review"), for: recoveredRun)
- let read = try await result
-
- #expect(read.core.lifecycle.status == .succeeded)
- #expect(read.core.output.lastAgentMessage == "recovered review")
- }
- }
-
- @Test func reviewStartCancellationInterruptsBackendRun() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
- )
- try #require(await StoreSnapshotProbe(store: store).waitUntilJobStatus(.running, jobID: "job-1") != nil)
- await backend.finishEvents(throwing: CancellationError())
- let read = try await result
-
- #expect(read.core.lifecycle.status == .cancelled)
- let commands = await backend.recordedCommands()
- #expect(commands.contains(.interruptReview(
- .init(threadID: "thread-1", turnID: "turn-1", reviewThreadID: "review-thread-1"),
- .init(message: "Cancellation requested.")
- )))
- }
- }
-
- @Test func reviewStartTaskCancellationInterruptsBackendRun() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- let task = Task { @MainActor in
- try await store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
- )
- }
- task.cancel()
- let read = try await task.value
-
- #expect(read.core.lifecycle.status == .cancelled)
- let commands = await backend.recordedCommands()
- #expect(commands.contains(.interruptReview(
- .init(threadID: "thread-1", turnID: "turn-1", reviewThreadID: "review-thread-1"),
- .init(message: "Cancellation requested.")
- )))
- }
- }
-
- @Test func failedInterruptClearsCancellationRequestState() async throws {
- let backend = FakeCodexReviewBackend()
- await backend.failInterrupts(message: "Interrupt failed")
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
- )
- try #require(await StoreSnapshotProbe(store: store).waitUntilJobStatus(.running, jobID: "job-1") != nil)
- await #expect(throws: FakeCodexReviewBackendError.self) {
- try await store.cancelReview(
- jobID: "job-1",
- cancellation: .mcpClient(message: "Stop")
- )
- }
- let readAfterFailure = try store.readReview(jobID: "job-1")
-
- #expect(readAfterFailure.cancellable)
- #expect(readAfterFailure.core.lifecycle.cancellation == nil)
- #expect(readAfterFailure.core.output.summary == "Failed to cancel review: Interrupt failed")
-
- await backend.yield(.completed(summary: "Succeeded.", result: "review text"))
- _ = try await result
- }
- }
-
- @Test func cancelledReviewIgnoresBufferedTerminalEvents() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .baseBranch("main"))
- )
- try #require(await StoreSnapshotProbe(store: store).waitUntilJobStatus(.running, jobID: "job-1") != nil)
- _ = try await store.cancelReview(
- jobID: "job-1",
- cancellation: .mcpClient(message: "Stop")
- )
- await backend.yield(.completed(summary: "Succeeded.", result: "late result"))
- let read = try await result
-
- #expect(read.core.lifecycle.status == .cancelled)
- #expect(read.core.output.summary == "Stop")
- #expect(read.core.output.lastAgentMessage == nil)
- }
- }
-
- @Test func terminalEventDuringPendingCancellationKeepsCancelledState() async throws {
- let backend = FakeCodexReviewBackend()
- let interruptGate = AsyncGate()
- await backend.holdInterruptReview(with: interruptGate)
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- )
- async let cancel = store.cancelReview(jobID: "job-1", cancellation: .mcpClient(message: "Stop"))
- try await backend.waitForInterruptReview(timeout: .seconds(2))
- await backend.yield(.completed(summary: "Reviewer failed to output a response.", result: nil))
- await interruptGate.open()
- _ = try await cancel
- let read = try await result
-
- #expect(read.core.lifecycle.status == .cancelled)
- #expect(read.core.output.summary == "Stop")
- #expect(read.core.output.hasFinalReview == false)
- }
- }
-
- @Test func cancelDuringReviewStartupInterruptsAfterRunBecomesAvailable() async throws {
- let backend = FakeCodexReviewBackend()
- let gate = AsyncGate()
- await backend.holdStartReview(with: gate)
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- )
- try await backend.waitForStartReview(timeout: .seconds(2))
- let cancel = try await store.cancelReview(jobID: "job-1", cancellation: .mcpClient(message: "Stop"))
- let cancelledDuringStartup = try #require(store.jobs.first)
- #expect(cancel.core.lifecycle.status == .cancelled)
- #expect(cancelledDuringStartup.core.lifecycle.status == .cancelled)
- await gate.open()
- let read = try await result
-
- #expect(cancel.cancelled)
- #expect(read.core.lifecycle.status == .cancelled)
- let commands = await backend.recordedCommands()
- #expect(commands.contains(.interruptReview(
- .init(threadID: "thread-1", turnID: "turn-1", reviewThreadID: "review-thread-1"),
- .init(message: "Stop")
- )))
- #expect(commands.contains(.cleanupReview(.init(
- threadID: "thread-1",
- turnID: "turn-1",
- reviewThreadID: "review-thread-1"
- ))))
- }
- }
-
- @Test func closedSessionRejectsNewReviews() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
- )
- await withStoreCommandTestCleanup(backend: backend, store: store) {
- await store.closeSession("session-1")
-
- await #expect(throws: (any Error).self) {
- try await store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- )
- }
- }
- }
-
- @Test func closeActiveReviewSessionsCancelsJobsWithoutClosingMCPServerSession() async throws {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend),
- idGenerator: .init(next: { "job-1" })
- )
- try await withStoreCommandTestCleanup(backend: backend, store: store) {
- let running = CodexReviewJob.makeForTesting(
- id: "running-job",
- sessionID: "session-1",
- cwd: "/tmp/project",
- targetSummary: "Running",
- status: .running,
- summary: "Running"
- )
- store.loadForTesting(
- serverState: .running,
- workspaces: [.init(cwd: "/tmp/project")],
- jobs: [running]
- )
-
- await store.closeActiveReviewSessions(reason: .system(message: "Account switched."))
-
- #expect(running.core.lifecycle.status == .cancelled)
- async let result = store.startReview(
- sessionID: "session-1",
- request: .init(cwd: "/tmp/project", target: .uncommittedChanges)
- )
- await backend.yield(.completed(summary: "Succeeded.", result: "review text"))
- let read = try await result
-
- #expect(read.jobID == "job-1")
- #expect(read.core.lifecycle.status == .succeeded)
- }
- }
-
- @Test func authAndSettingsUseSingleBackendContract() async throws {
- let backend = FakeCodexReviewBackend(settings: .init(model: "gpt-5"))
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
- )
- await withStoreCommandTestCleanup(backend: backend, store: store) {
- await store.refreshSettings()
-
- #expect(store.settings.effectiveModel == "gpt-5")
- }
- }
-
- @Test func initialActiveAccountKeySelectsPersistedAccount() {
- let active = CodexAccount(email: "active@example.com")
- let inactive = CodexAccount(email: "inactive@example.com")
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(
- reviewBackend: backend,
- seed: .init(
- initialAccounts: [inactive, active],
- initialActiveAccountKey: active.accountKey
- )
- )
- )
-
- #expect(store.auth.persistedAccounts.map(\.accountKey) == [
- inactive.accountKey,
- active.accountKey,
- ])
- #expect(store.auth.persistedActiveAccountKey == active.accountKey)
- #expect(store.auth.selectedAccount?.accountKey == active.accountKey)
- }
-
- @Test func switchActionsAreUnavailableForSelectedAccount() async throws {
- let selectedAccount = CodexAccount(email: "selected@example.com", planType: "pro")
- let otherAccount = CodexAccount(email: "other@example.com", planType: "plus")
- let backend = SwitchRecordingBackend()
- let store = CodexReviewStore.makeTestingStore(backend: backend)
- store.loadForTesting(
- serverState: .running,
- account: selectedAccount,
- persistedAccounts: [selectedAccount, otherAccount],
- workspaces: []
- )
- let displayedSelectedAccount = try #require(store.auth.selectedAccount)
- let displayedOtherAccount = try #require(
- store.auth.persistedAccounts.first { $0.accountKey == otherAccount.accountKey }
- )
-
- #expect(store.switchActionIsDisabled(for: displayedSelectedAccount))
- #expect(store.switchActionRequiresRunningJobsConfirmation(for: displayedSelectedAccount) == false)
- #expect(store.switchActionIsDisabled(for: displayedOtherAccount) == false)
- #expect(store.switchActionRequiresRunningJobsConfirmation(for: displayedOtherAccount))
-
- store.requestSwitchAccountFromUserAction(displayedSelectedAccount)
- await Task.yield()
- #expect(backend.switchRequests.isEmpty)
-
- try await store.switchAccount(displayedSelectedAccount)
- #expect(backend.switchRequests.isEmpty)
-
- try await store.switchAccount(displayedOtherAccount)
- #expect(backend.switchRequests == [displayedOtherAccount.accountKey])
- }
-
- @Test func fakeBackendPreservesSettingsCatalogWhenApplyingOverrides() async throws {
- let model = CodexReviewSettings.ModelCatalogItem(
- id: "gpt-5.5",
- model: "gpt-5.5",
- displayName: "GPT-5.5",
- hidden: false,
- supportedReasoningEfforts: [
- .init(reasoningEffort: .medium, description: "Balanced"),
- ],
- defaultReasoningEffort: .medium,
- supportedServiceTiers: [.fast],
- isDefault: true
- )
- let backend = FakeCodexReviewBackend(settings: .init(
- fallbackModel: "gpt-5.5",
- models: [model]
- ))
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
- )
- await withStoreCommandTestCleanup(backend: backend, store: store) {
- await store.refreshSettings()
- await store.updateSettingsReasoningEffort(.medium)
-
- #expect(store.settings.effectiveModel == "gpt-5.5")
- #expect(store.settings.models == [model])
- }
- }
-
- @Test func primaryAuthenticationActionIsAvailableWhenRuntimeCanRecoverOrStartLogin() {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
- )
-
- store.loadForTesting(serverState: .stopped, authPhase: .signedOut, workspaces: [])
- #expect(store.canPerformPrimaryAuthenticationAction)
-
- store.loadForTesting(serverState: .failed("Runtime failed."), authPhase: .signedOut, workspaces: [])
- #expect(store.canPerformPrimaryAuthenticationAction)
-
- store.loadForTesting(serverState: .starting, authPhase: .signedOut, workspaces: [])
- #expect(store.canPerformPrimaryAuthenticationAction == false)
-
- store.loadForTesting(serverState: .running, authPhase: .signedOut, workspaces: [])
- #expect(store.canPerformPrimaryAuthenticationAction)
-
- store.auth.updatePhase(.signingIn(.init(title: "Sign in", detail: "Open browser.")))
- store.transitionToFailed("Runtime failed.")
- #expect(store.canPerformPrimaryAuthenticationAction)
- }
-
- @Test func primaryAuthenticationActionRestartsRecoverableRuntimeBeforeLogin() async {
- let backend = FakeCodexReviewBackend()
- let store = CodexReviewStore.makeTestingStore(
- backend: TestingCodexReviewStoreBackend(reviewBackend: backend)
- )
- await withStoreCommandTestCleanup(backend: backend, store: store) {
- store.loadForTesting(serverState: .failed("Runtime failed."), authPhase: .signedOut, workspaces: [])
-
- await store.performPrimaryAuthenticationAction()
-
- #expect(store.serverState == .running)
- #expect(store.auth.isAuthenticating)
- let commands = await backend.recordedCommands()
- #expect(commands.contains { command in
- if case .startLogin = command {
- return true
- }
- return false
- })
- }
- }
-}
-
-@MainActor
-private final class SwitchRecordingBackend: PreviewCodexReviewStoreBackend {
- private(set) var switchRequests: [String] = []
-
- override func switchAccount(
- auth _: CodexReviewAuthModel,
- accountKey: String
- ) async throws {
- switchRequests.append(accountKey)
- }
-
- override func requiresCurrentSessionRecovery(
- auth _: CodexReviewAuthModel,
- accountKey _: String
- ) -> Bool {
- true
- }
-}
-
-@MainActor
-private func waitUntil(
- timeout: Duration = .seconds(2),
- condition: () async -> Bool
-) async -> Bool {
- let clock = ContinuousClock()
- let deadline = clock.now + timeout
- while await condition() == false {
- if clock.now >= deadline {
- return false
- }
- try? await Task.sleep(for: .milliseconds(10))
- }
- return true
-}
-
-@MainActor
-private func waitForRunAttemptActivation(
- store: CodexReviewStore,
- run: CodexReviewBackendModel.Review.Run,
- timeout: Duration = .seconds(2)
-) async -> Bool {
- await StoreSnapshotProbe(store: store)
- .waitUntilRunAttempt(run.attemptID, timeout: timeout) != nil
-}
-
-private func waitForTaskValue(
- _ task: Task,
- timeout: Duration
-) async throws -> T? {
- try await withThrowingTaskGroup(of: T?.self) { group in
- group.addTask {
- try await task.value
- }
- group.addTask {
- try await Task.sleep(for: timeout)
- return nil
- }
- let result = try await group.next() ?? nil
- group.cancelAll()
- return result
- }
-}
-
-@MainActor
-private func withStoreCommandTestCleanup(
- backend: FakeCodexReviewBackend,
- store: CodexReviewStore,
- operation: () async throws -> Void
-) async rethrows {
- do {
- try await operation()
- } catch {
- await cleanupStoreCommandTest(backend: backend, store: store)
- throw error
- }
- await cleanupStoreCommandTest(backend: backend, store: store)
-}
-
-@MainActor
-private func cleanupStoreCommandTest(
- backend: FakeCodexReviewBackend,
- store: CodexReviewStore
-) async {
- await backend.finishEventMailboxes()
- await store.cancelAndDrainReviewWorkersForTesting()
- await backend.finishEventMailboxes()
-}
-
-private struct StreamClosedError: Error {}
-
-private actor ControlledTestSleeper {
- private let gate: AsyncGate
- private var shouldBlock = false
-
- init(gate: AsyncGate) {
- self.gate = gate
- }
-
- func blockFutureSleeps() {
- shouldBlock = true
- }
-
- func sleep() async {
- if shouldBlock {
- await gate.wait()
- }
- }
-}
-
-private final class MutableTestClock: @unchecked Sendable {
- var current: Date
-
- init(_ current: Date) {
- self.current = current
- }
-
- func now() -> Date {
- current
- }
-}
diff --git a/Tests/ReviewMonitorRenderingTests/ReviewTimelineDocumentRendererTests.swift b/Tests/ReviewMonitorRenderingTests/ReviewTimelineDocumentRendererTests.swift
deleted file mode 100644
index 20e7ffa7..00000000
--- a/Tests/ReviewMonitorRenderingTests/ReviewTimelineDocumentRendererTests.swift
+++ /dev/null
@@ -1,654 +0,0 @@
-import Foundation
-import Testing
-import CodexReviewDomain
-@testable import ReviewMonitorRendering
-
-@MainActor
-@Suite("review timeline document renderer")
-struct ReviewTimelineDocumentRendererTests {
- @Test func documentUsesTimelineOrderingStableBlockIDsAndActiveState() throws {
- let timeline = ReviewTimeline()
-
- timeline.apply(.itemStarted(.init(
- id: "cmd-1",
- kind: .commandExecution,
- family: .command,
- phase: .running,
- content: .command(.init(command: "swift test", status: .inProgress))
- )))
- timeline.apply(.itemCompleted(.init(
- id: "msg-1",
- kind: .agentMessage,
- family: .message,
- phase: .completed,
- content: .message(.init(text: "done"))
- )))
-
- let document = ReviewTimelineDocumentRenderer().document(from: timeline)
-
- #expect(document.blocks.map(\.sourceItemID) == [itemID("cmd-1"), itemID("msg-1")])
- #expect(document.orderedBlockIDs == [blockID("cmd-1"), blockID("msg-1")])
- #expect(document.activeBlockIDs == [blockID("cmd-1")])
- #expect(document.activeBlockCount == 1)
- #expect(document.latestActivityBlockID == blockID("msg-1"))
-
- let commandBlock = try requireBlock(document, id: "cmd-1")
- #expect(commandBlock.id == blockID("cmd-1"))
- #expect(commandBlock.kind == .commandExecution)
- #expect(commandBlock.family == .command)
- #expect(commandBlock.phase == .running)
- #expect(commandBlock.isActive)
-
- let messageBlock = try requireBlock(document, id: "msg-1")
- #expect(messageBlock.phase == .completed)
- #expect(messageBlock.isActive == false)
- }
-
- @Test func documentPreservesSemanticPayloadsForTimelineFamilies() throws {
- let timeline = ReviewTimeline()
-
- start(
- timeline,
- id: "approval-1",
- kind: "approvalRequest",
- family: .approval,
- phase: .awaitingApproval,
- content: .approval(.init(
- title: "Run command?",
- detail: "swift test",
- decision: .approved,
- scope: "command",
- risk: .medium,
- status: .decided
- ))
- )
- complete(
- timeline,
- id: "cmd-1",
- kind: .commandExecution,
- family: .command,
- content: .command(.init(
- command: "swift test",
- cwd: "/repo",
- output: "ok",
- exitCode: 0,
- status: .completed,
- source: "appServer",
- processID: "pid-1",
- actions: [
- .init(kind: .read, path: "Sources/App.swift"),
- .init(kind: .search, command: "rg ReviewTimeline", query: "ReviewTimeline"),
- ],
- durationMs: 1_500
- )),
- durationMs: 1_500
- )
- complete(
- timeline,
- id: "tool-1",
- kind: .mcpToolCall,
- family: .tool,
- content: .toolCall(.init(
- namespace: "mcp",
- server: "codex_review",
- tool: "review_start",
- arguments: #"{"target":"baseBranch"}"#,
- result: "No findings.",
- error: nil,
- status: .completed,
- durationMs: 2_000,
- appContext: "reviewMonitor",
- pluginID: "codex-review",
- callID: "call-1",
- progress: "awaiting backend"
- )),
- durationMs: 2_000
- )
- complete(
- timeline,
- id: "file-1",
- kind: .fileChange,
- family: .fileChange,
- content: .fileChange(.init(
- title: "Sources/App.swift",
- output: "modified",
- paths: ["Sources/App.swift"],
- patch: "@@ patch",
- status: .updated
- ))
- )
- complete(
- timeline,
- id: "search-1",
- kind: .webSearch,
- family: .search,
- content: .search(.init(
- query: "ReviewTimeline",
- result: "2 results",
- status: .completed,
- resultCount: 2,
- durationMs: 300
- ))
- )
- start(
- timeline,
- id: "diagnostic-1",
- kind: "backendDiagnostic",
- family: .diagnostic,
- phase: .running,
- content: .diagnostic(.init(
- message: "Backend overloaded",
- severity: .warning,
- retry: .init(state: .scheduled, attempt: 1, maxAttempts: 3, delayMs: 500)
- ))
- )
- complete(
- timeline,
- id: "context-1",
- kind: .contextCompaction,
- family: .contextCompaction,
- content: .contextCompaction(.init(
- title: "Compacting context",
- status: .completed,
- inputTokens: 10_000,
- outputTokens: 2_500
- ))
- )
- complete(
- timeline,
- id: "message-1",
- kind: .agentMessage,
- family: .message,
- content: .message(.init(text: "Agent says hi"))
- )
- complete(
- timeline,
- id: "plan-1",
- kind: .plan,
- family: .plan,
- content: .plan(.init(markdown: "- [ ] Task"))
- )
- complete(
- timeline,
- id: "reasoning-1",
- kind: .reasoning,
- family: .reasoning,
- content: .reasoning(.init(text: "Thinking", style: .summary))
- )
- complete(
- timeline,
- id: "unknown-1",
- kind: "futureItem",
- family: .unknown,
- content: .unknown(.init(
- title: "Future item",
- detail: "raw detail",
- rawKind: "futureAppServerItem",
- rawStatus: "needsExternalDecision",
- references: [
- .init(kind: "wirePayload", value: "payload-1", label: "Wire payload"),
- ]
- ))
- )
-
- let document = ReviewTimelineDocumentRenderer().document(from: timeline)
-
- let approval = try requireApproval(document, id: "approval-1")
- #expect(approval.decision == .approved)
- #expect(approval.scope == "command")
- #expect(approval.risk == .medium)
- #expect(approval.status == .decided)
-
- let commandBlock = try requireBlock(document, id: "cmd-1")
- let command = try requireCommand(commandBlock)
- #expect(command.title == "swift test")
- #expect(command.command == "swift test")
- #expect(command.cwd == "/repo")
- #expect(command.output == "ok")
- #expect(command.exitCode == 0)
- #expect(command.status == .completed)
- #expect(command.source == "appServer")
- #expect(command.processID == "pid-1")
- #expect(command.actions.map(\.kind) == [.read, .search])
- #expect(command.actions[0].path == "Sources/App.swift")
- #expect(command.actions[1].query == "ReviewTimeline")
- #expect(command.durationMs == 1_500)
- #expect(commandBlock.durationMs == 1_500)
-
- let tool = try requireToolCall(document, id: "tool-1")
- #expect(tool.namespace == "mcp")
- #expect(tool.server == "codex_review")
- #expect(tool.name == "review_start")
- #expect(tool.arguments == #"{"target":"baseBranch"}"#)
- #expect(tool.result == "No findings.")
- #expect(tool.error == nil)
- #expect(tool.status == .completed)
- #expect(tool.durationMs == 2_000)
- #expect(tool.appContext == "reviewMonitor")
- #expect(tool.pluginID == "codex-review")
- #expect(tool.callID == "call-1")
- #expect(tool.progress == "awaiting backend")
-
- let fileChange = try requireFileChange(document, id: "file-1")
- #expect(fileChange.paths == ["Sources/App.swift"])
- #expect(fileChange.patch == "@@ patch")
- #expect(fileChange.status == .updated)
- #expect(fileChange.output == "modified")
-
- let search = try requireSearch(document, id: "search-1")
- #expect(search.query == "ReviewTimeline")
- #expect(search.result == "2 results")
- #expect(search.status == .completed)
- #expect(search.resultCount == 2)
- #expect(search.durationMs == 300)
-
- let diagnostic = try requireDiagnostic(document, id: "diagnostic-1")
- #expect(diagnostic.severity == .warning)
- #expect(diagnostic.retry?.state == .scheduled)
- #expect(diagnostic.retry?.attempt == 1)
- #expect(diagnostic.retry?.maxAttempts == 3)
- #expect(diagnostic.retry?.delayMs == 500)
-
- let contextCompaction = try requireContextCompaction(document, id: "context-1")
- #expect(contextCompaction.status == .completed)
- #expect(contextCompaction.inputTokens == 10_000)
- #expect(contextCompaction.outputTokens == 2_500)
-
- let message = try requireMessage(document, id: "message-1")
- #expect(message.text == "Agent says hi")
-
- let plan = try requirePlan(document, id: "plan-1")
- #expect(plan.markdown == "- [ ] Task")
-
- let reasoning = try requireReasoning(document, id: "reasoning-1")
- #expect(reasoning.text == "Thinking")
- #expect(reasoning.style == .summary)
-
- let unknown = try requireUnknown(document, id: "unknown-1")
- #expect(unknown.rawKind == "futureAppServerItem")
- #expect(unknown.rawStatus == "needsExternalDecision")
- #expect(unknown.references == [
- .init(kind: "wirePayload", value: "payload-1", label: "Wire payload"),
- ])
- }
-
- @Test func plainTextIsDerivedFromDocumentAndMatchesLegacyText() throws {
- let timeline = ReviewTimeline()
-
- complete(
- timeline,
- id: "approval-1",
- kind: "approvalRequest",
- family: .approval,
- content: .approval(.init(title: "Run command?", detail: "swift test"))
- )
- complete(
- timeline,
- id: "cmd-1",
- kind: .commandExecution,
- family: .command,
- content: .command(.init(command: "swift test", output: "ok"))
- )
- complete(
- timeline,
- id: "context-1",
- kind: .contextCompaction,
- family: .contextCompaction,
- content: .contextCompaction(.init(title: "Compacting context"))
- )
- complete(
- timeline,
- id: "diagnostic-1",
- kind: "backendDiagnostic",
- family: .diagnostic,
- content: .diagnostic(.init(message: "Backend overloaded"))
- )
- complete(
- timeline,
- id: "file-1",
- kind: .fileChange,
- family: .fileChange,
- content: .fileChange(.init(title: "Sources/App.swift", output: "modified"))
- )
- complete(
- timeline,
- id: "message-1",
- kind: .agentMessage,
- family: .message,
- content: .message(.init(text: "Agent says hi"))
- )
- complete(
- timeline,
- id: "plan-1",
- kind: .plan,
- family: .plan,
- content: .plan(.init(markdown: "- [ ] Task"))
- )
- complete(
- timeline,
- id: "reasoning-1",
- kind: .reasoning,
- family: .reasoning,
- content: .reasoning(.init(text: "Thinking", style: .raw))
- )
- complete(
- timeline,
- id: "search-1",
- kind: .webSearch,
- family: .search,
- content: .search(.init(query: "ReviewTimeline", result: "2 results"))
- )
- complete(
- timeline,
- id: "tool-1",
- kind: .mcpToolCall,
- family: .tool,
- content: .toolCall(.init(namespace: "mcp", server: "codex_review", tool: "review_start"))
- )
- complete(
- timeline,
- id: "unknown-1",
- kind: "futureItem",
- family: .unknown,
- content: .unknown(.init(title: "Future item", detail: "raw detail"))
- )
-
- let renderer = ReviewTimelineDocumentRenderer()
- let document = renderer.document(from: timeline)
-
- #expect(renderer.plainText(from: timeline) == document.plainText)
- #expect(document.blocks.map(\.primaryText) == [
- "Run command?",
- "swift test",
- "Compacting context",
- "Backend overloaded",
- "Sources/App.swift",
- "Agent says hi",
- "- [ ] Task",
- "Thinking",
- "ReviewTimeline",
- "mcp.codex_review.review_start",
- "Future item",
- ])
- #expect(renderer.plainText(from: timeline) == """
- Run command?
- swift test
-
- $ swift test
- ok
-
- Compacting context
-
- Backend overloaded
-
- Sources/App.swift
- modified
-
- Agent says hi
-
- - [ ] Task
-
- Thinking
-
- ReviewTimeline
- 2 results
-
- mcp.codex_review.review_start
-
- Future item
- raw detail
- """)
- }
-
- @Test func outputOnlyCommandRawTranscriptDoesNotSynthesizeCommandPrompt() throws {
- let timeline = ReviewTimeline()
- complete(
- timeline,
- id: "process-1",
- kind: .commandExecution,
- family: .command,
- content: .command(.init(command: "", output: "Build complete\n"))
- )
-
- let document = ReviewTimelineDocumentRenderer().document(from: timeline)
- let block = try requireBlock(document, id: "process-1")
-
- #expect(block.rawTranscriptText == "Build complete\n")
- #expect(document.plainText == "Build complete\n")
- }
-
- @Test func toolCallRawTranscriptIncludesPayloadText() throws {
- let timeline = ReviewTimeline()
- complete(
- timeline,
- id: "tool-result",
- kind: .mcpToolCall,
- family: .tool,
- content: .toolCall(.init(
- namespace: "mcp",
- server: "codex_review",
- tool: "review_read",
- result: "Review completed."
- ))
- )
- complete(
- timeline,
- id: "tool-error",
- kind: .mcpToolCall,
- family: .tool,
- content: .toolCall(.init(
- namespace: "mcp",
- server: "codex_review",
- tool: "review_start",
- error: "Review failed."
- ))
- )
- complete(
- timeline,
- id: "tool-progress",
- kind: .mcpToolCall,
- family: .tool,
- content: .toolCall(.init(
- namespace: "mcp",
- server: "codex_review",
- tool: "review_await",
- progress: "Waiting for review."
- ))
- )
-
- let document = ReviewTimelineDocumentRenderer().document(from: timeline)
-
- #expect(try requireBlock(document, id: "tool-result").rawTranscriptText == """
- mcp.codex_review.review_read
- Review completed.
- """)
- #expect(try requireBlock(document, id: "tool-error").rawTranscriptText == """
- mcp.codex_review.review_start
- Review failed.
- """)
- #expect(try requireBlock(document, id: "tool-progress").rawTranscriptText == """
- mcp.codex_review.review_await
- Waiting for review.
- """)
- #expect(document.plainText.contains("Review completed."))
- #expect(document.plainText.contains("Review failed."))
- #expect(document.plainText.contains("Waiting for review."))
- }
-}
-
-@MainActor
-private func start(
- _ timeline: ReviewTimeline,
- id: ReviewTimelineItem.ID,
- kind: ReviewItemKind,
- family: ReviewItemFamily,
- phase: ReviewItemPhase = .running,
- content: ReviewTimelineItem.Content,
- durationMs: Int? = nil
-) {
- timeline.apply(.itemStarted(.init(
- id: id,
- kind: kind,
- family: family,
- phase: phase,
- content: content,
- durationMs: durationMs
- )))
-}
-
-@MainActor
-private func complete(
- _ timeline: ReviewTimeline,
- id: ReviewTimelineItem.ID,
- kind: ReviewItemKind,
- family: ReviewItemFamily,
- phase: ReviewItemPhase = .completed,
- content: ReviewTimelineItem.Content,
- durationMs: Int? = nil
-) {
- timeline.apply(.itemCompleted(.init(
- id: id,
- kind: kind,
- family: family,
- phase: phase,
- content: content,
- durationMs: durationMs
- )))
-}
-
-private func itemID(_ rawValue: String) -> ReviewTimelineItem.ID {
- .init(rawValue: rawValue)
-}
-
-private func blockID(_ rawValue: String) -> ReviewTimelineDocument.Block.ID {
- .init(rawValue: rawValue)
-}
-
-private func requireBlock(
- _ document: ReviewTimelineDocument,
- id: ReviewTimelineItem.ID
-) throws -> ReviewTimelineDocument.Block {
- try #require(document.blocks.first { $0.sourceItemID == id })
-}
-
-private func requireApproval(
- _ document: ReviewTimelineDocument,
- id: ReviewTimelineItem.ID
-) throws -> ReviewTimelineDocument.Approval {
- guard case .approval(let approval) = try requireBlock(document, id: id).content else {
- Issue.record("expected approval content")
- throw TestFailure()
- }
- return approval
-}
-
-private func requireCommand(
- _ block: ReviewTimelineDocument.Block
-) throws -> ReviewTimelineDocument.Command {
- guard case .command(let command) = block.content else {
- Issue.record("expected command content")
- throw TestFailure()
- }
- return command
-}
-
-private func requireToolCall(
- _ document: ReviewTimelineDocument,
- id: ReviewTimelineItem.ID
-) throws -> ReviewTimelineDocument.ToolCall {
- guard case .toolCall(let toolCall) = try requireBlock(document, id: id).content else {
- Issue.record("expected tool call content")
- throw TestFailure()
- }
- return toolCall
-}
-
-private func requireFileChange(
- _ document: ReviewTimelineDocument,
- id: ReviewTimelineItem.ID
-) throws -> ReviewTimelineDocument.FileChange {
- guard case .fileChange(let fileChange) = try requireBlock(document, id: id).content else {
- Issue.record("expected file change content")
- throw TestFailure()
- }
- return fileChange
-}
-
-private func requireSearch(
- _ document: ReviewTimelineDocument,
- id: ReviewTimelineItem.ID
-) throws -> ReviewTimelineDocument.Search {
- guard case .search(let search) = try requireBlock(document, id: id).content else {
- Issue.record("expected search content")
- throw TestFailure()
- }
- return search
-}
-
-private func requireDiagnostic(
- _ document: ReviewTimelineDocument,
- id: ReviewTimelineItem.ID
-) throws -> ReviewTimelineDocument.Diagnostic {
- guard case .diagnostic(let diagnostic) = try requireBlock(document, id: id).content else {
- Issue.record("expected diagnostic content")
- throw TestFailure()
- }
- return diagnostic
-}
-
-private func requireContextCompaction(
- _ document: ReviewTimelineDocument,
- id: ReviewTimelineItem.ID
-) throws -> ReviewTimelineDocument.ContextCompaction {
- guard case .contextCompaction(let contextCompaction) = try requireBlock(document, id: id).content else {
- Issue.record("expected context compaction content")
- throw TestFailure()
- }
- return contextCompaction
-}
-
-private func requireMessage(
- _ document: ReviewTimelineDocument,
- id: ReviewTimelineItem.ID
-) throws -> ReviewTimelineDocument.Message {
- guard case .message(let message) = try requireBlock(document, id: id).content else {
- Issue.record("expected message content")
- throw TestFailure()
- }
- return message
-}
-
-private func requirePlan(
- _ document: ReviewTimelineDocument,
- id: ReviewTimelineItem.ID
-) throws -> ReviewTimelineDocument.Plan {
- guard case .plan(let plan) = try requireBlock(document, id: id).content else {
- Issue.record("expected plan content")
- throw TestFailure()
- }
- return plan
-}
-
-private func requireReasoning(
- _ document: ReviewTimelineDocument,
- id: ReviewTimelineItem.ID
-) throws -> ReviewTimelineDocument.Reasoning {
- guard case .reasoning(let reasoning) = try requireBlock(document, id: id).content else {
- Issue.record("expected reasoning content")
- throw TestFailure()
- }
- return reasoning
-}
-
-private func requireUnknown(
- _ document: ReviewTimelineDocument,
- id: ReviewTimelineItem.ID
-) throws -> ReviewTimelineDocument.Unknown {
- guard case .unknown(let unknown) = try requireBlock(document, id: id).content else {
- Issue.record("expected unknown content")
- throw TestFailure()
- }
- return unknown
-}
-
-private struct TestFailure: Error {}
diff --git a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift
new file mode 100644
index 00000000..4118e7e3
--- /dev/null
+++ b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift
@@ -0,0 +1,990 @@
+import Foundation
+import Testing
+import CodexKit
+@_spi(Testing) @testable import CodexReviewKit
+@testable import ReviewChatLogUI
+@testable import ReviewUI
+@testable import ReviewUIPreviewSupport
+
+@MainActor
+func makeReviewMonitorPreviewContentViewControllerForPreview(
+ authPhase: CodexReviewAuthModel.Phase = .signedOut,
+ account: CodexReviewAccount? = nil,
+ serverState: CodexReviewServerState = .running,
+ previewContent: ReviewMonitorPreviewContentSource? = nil
+) -> ReviewMonitorRootViewController {
+ let viewController = ReviewUIPreviewSupport.makeReviewMonitorPreviewContentViewControllerForPreview(
+ authPhase: authPhase,
+ account: account,
+ serverState: serverState,
+ previewContent: previewContent
+ )
+ guard let rootViewController = viewController as? ReviewMonitorRootViewController else {
+ fatalError("Expected ReviewMonitorRootViewController.")
+ }
+ return rootViewController
+}
+
+struct ReviewChatLogEntryForTesting: Sendable, Hashable {
+ enum Kind: String, Sendable, Hashable {
+ case command
+ case commandOutput
+ case fileChange
+ case agentMessage
+ case plan
+ case todoList
+ case reasoning
+ case reasoningSummary
+ case rawReasoning
+ case contextCompaction
+ case toolCall
+ case diagnostic
+ case error
+ case progress
+ case event
+ }
+
+ struct Metadata: Sendable, Hashable {
+ var sourceType: String?
+ var title: String?
+ var status: String?
+ var itemID: String?
+ var command: String?
+ var cwd: String?
+ var exitCode: Int?
+ var startedAt: Date?
+ var completedAt: Date?
+ var durationMs: Int?
+ var commandStatus: String?
+
+ init(
+ sourceType: String? = nil,
+ title: String? = nil,
+ status: String? = nil,
+ itemID: String? = nil,
+ command: String? = nil,
+ cwd: String? = nil,
+ exitCode: Int? = nil,
+ startedAt: Date? = nil,
+ completedAt: Date? = nil,
+ durationMs: Int? = nil,
+ commandStatus: String? = nil
+ ) {
+ self.sourceType = sourceType
+ self.title = title
+ self.status = status
+ self.itemID = itemID
+ self.command = command
+ self.cwd = cwd
+ self.exitCode = exitCode
+ self.startedAt = startedAt
+ self.completedAt = completedAt
+ self.durationMs = durationMs
+ self.commandStatus = commandStatus
+ }
+ }
+
+ var id: UUID
+ var kind: Kind
+ var groupID: String?
+ var replacesGroup: Bool
+ var text: String
+ var metadata: Metadata?
+
+ init(
+ id: UUID = UUID(),
+ kind: Kind,
+ groupID: String? = nil,
+ replacesGroup: Bool = false,
+ text: String,
+ metadata: Metadata? = nil
+ ) {
+ self.id = id
+ self.kind = kind
+ self.groupID = groupID
+ self.replacesGroup = replacesGroup
+ self.text = text
+ self.metadata = metadata
+ }
+}
+
+@MainActor
+struct ReviewChatFixtureForTesting {
+ struct Chat: Sendable {
+ var rowID: ReviewMonitorCodexSidebarRowID
+ var id: CodexThreadID
+ var title: String
+ var preview: String?
+ var model: String?
+ var workspaceCWD: String?
+ var updatedAt: Date?
+ var recencyAt: Date?
+ var status: CodexThreadStatus?
+ }
+
+ var id: String
+ var cwd: String
+ var chat: Chat
+ var turnID: CodexTurnID
+ var streamID: String
+ var isRunning: Bool
+ var initialSnapshot: CodexThreadSnapshot
+
+ var chatID: CodexThreadID {
+ chat.id
+ }
+}
+
+enum ReviewChatFixtureStatus: Sendable, Hashable {
+ case queued
+ case running
+ case succeeded
+ case failed
+ case cancelled
+
+ var isTerminal: Bool {
+ switch self {
+ case .queued, .running:
+ false
+ case .succeeded, .failed, .cancelled:
+ true
+ }
+ }
+
+ var displayText: String {
+ switch self {
+ case .queued:
+ "Queued"
+ case .running:
+ "Running"
+ case .succeeded:
+ "Succeeded"
+ case .failed:
+ "Failed"
+ case .cancelled:
+ "Cancelled"
+ }
+ }
+}
+
+@MainActor
+func makeReviewChatFixtureForTesting(
+ id: String = UUID().uuidString,
+ cwd: String = "/tmp/repo",
+ title: String,
+ preview: String? = nil,
+ model: String? = "gpt-5",
+ chatID: CodexThreadID? = nil,
+ turnID: CodexTurnID? = nil,
+ status: ReviewChatFixtureStatus = .succeeded,
+ startedAt: Date? = Date(timeIntervalSince1970: 200),
+ updatedAt: Date? = nil,
+ chatEntries: [ReviewChatLogEntryForTesting] = [],
+ errorMessage: String? = nil
+) -> ReviewChatFixtureForTesting {
+ let resolvedChatID = chatID ?? CodexThreadID(rawValue: id)
+ let resolvedTurnID = turnID ?? CodexTurnID(rawValue: "\(id):preview-turn")
+ let resolvedUpdatedAt = updatedAt ?? startedAt
+ let chat = ReviewChatFixtureForTesting.Chat(
+ rowID: .chat(resolvedChatID),
+ id: resolvedChatID,
+ title: title,
+ preview: preview,
+ model: model,
+ workspaceCWD: cwd,
+ updatedAt: resolvedUpdatedAt,
+ recencyAt: resolvedUpdatedAt,
+ status: CodexThreadStatus(chatFixtureStatusForTesting: status)
+ )
+ ReviewChatLogFixtureStore.setEntries(chatEntries, for: resolvedChatID)
+ let initialSnapshot = makeCodexThreadSnapshotForTesting(
+ chatID: resolvedChatID,
+ turnID: resolvedTurnID,
+ turnStatus: CodexTurnStatus(chatFixtureStatusForTesting: status),
+ turnErrorDescription: errorMessage,
+ items: ReviewChatLogFixtureStore.items(for: resolvedChatID, turnID: resolvedTurnID)
+ )
+ return ReviewChatFixtureForTesting(
+ id: id,
+ cwd: cwd,
+ chat: chat,
+ turnID: resolvedTurnID,
+ streamID: id,
+ isRunning: status == .running,
+ initialSnapshot: initialSnapshot
+ )
+}
+
+@MainActor
+func appendChatLogEntryForTesting(
+ _ entry: ReviewChatLogEntryForTesting,
+ to chatID: CodexThreadID,
+ turnID: CodexTurnID
+) async {
+ await ReviewChatLogFixtureStore.append(entry, to: chatID, turnID: turnID)
+}
+
+@MainActor
+func replaceChatLogTextForTesting(
+ _ text: String,
+ for chatID: CodexThreadID,
+ fixtureID: String,
+ turnID: CodexTurnID
+) async {
+ await ReviewChatLogFixtureStore.replaceEntries(
+ [.init(kind: .agentMessage, groupID: "fixture-log-\(fixtureID)", text: text.trimmingCharacters(in: .newlines))],
+ for: chatID,
+ turnID: turnID
+ )
+}
+
+@MainActor
+@discardableResult
+func installPreviewChatLogSourceForTesting(
+ on store: CodexReviewStore,
+ fixtures: [ReviewChatFixtureForTesting]
+) -> ReviewMonitorPreviewAppServerRuntime {
+ let fixtures = fixtures.map(makePreviewChatLogFixtureForTesting)
+ let runtime = ReviewMonitorPreviewAppServerRuntime(fixtures: fixtures)
+ let retainer = ReviewChatLogFixtureRetainer(
+ store: store,
+ runtime: runtime,
+ chatIDs: Set(fixtures.map(\.chatID))
+ )
+ runStorePreviewSupportRetainers.append(retainer)
+ previewSupportRetainersByStore[ObjectIdentifier(store)] = retainer
+ return runtime
+}
+
+@MainActor
+func previewRuntimeForTesting(on store: CodexReviewStore) -> ReviewMonitorPreviewAppServerRuntime? {
+ prunePreviewSupportRetainersByStore()
+ guard let retainer = previewSupportRetainersByStore[ObjectIdentifier(store)],
+ retainer.store === store
+ else {
+ previewSupportRetainersByStore[ObjectIdentifier(store)] = nil
+ return nil
+ }
+ return retainer.runtime
+}
+
+@MainActor
+private func prunePreviewSupportRetainersByStore() {
+ previewSupportRetainersByStore = previewSupportRetainersByStore.filter { _, retainer in
+ retainer.store != nil
+ }
+}
+
+@MainActor
+func makeReviewMonitorSplitViewControllerForTesting(
+ store: CodexReviewStore,
+ uiState: ReviewMonitorUIState,
+ codexModelSource: ReviewMonitorCodexModelSource? = nil,
+ showSettings: (@MainActor () -> Void)? = nil
+) -> ReviewMonitorSplitViewController {
+ let previewRuntime = previewRuntimeForTesting(on: store)
+ previewRuntime?.start()
+ return ReviewMonitorSplitViewController(
+ store: store,
+ uiState: uiState,
+ codexModelSource: codexModelSource ?? previewRuntime?.modelSource,
+ showSettings: showSettings
+ )
+}
+
+@MainActor
+func makeReviewMonitorSplitViewControllerForTesting(
+ store: CodexReviewStore,
+ uiState: ReviewMonitorUIState,
+ modelContext: CodexModelContext,
+ showSettings: (@MainActor () -> Void)? = nil
+) -> ReviewMonitorSplitViewController {
+ ReviewMonitorSplitViewController(
+ store: store,
+ uiState: uiState,
+ modelContext: modelContext,
+ showSettings: showSettings
+ )
+}
+
+@MainActor
+func reviewChatLogText(for fixture: ReviewChatFixtureForTesting) -> String {
+ reviewChatLogText(
+ for: codexThreadSnapshotForTesting(fixture),
+ chatUpdatedAt: fixture.chat.updatedAt
+ )
+}
+
+@MainActor
+func reviewChatLogText(
+ for snapshot: CodexThreadSnapshot,
+ chatUpdatedAt: Date? = nil
+) -> String {
+ ReviewChatLogFixtureStore.logText(for: snapshot, chatUpdatedAt: chatUpdatedAt)
+}
+
+@MainActor
+func awaitChatRenderForTesting(
+ _ fixture: ReviewChatFixtureForTesting,
+ in transport: ReviewMonitorTransportViewController,
+ allowIncrementalUpdate: Bool = true,
+ timeout: Duration = .seconds(2),
+ matching predicate: (@Sendable (ReviewMonitorTransportViewController.RenderSnapshotForTesting) -> Bool)? = nil
+) async throws -> ReviewMonitorTransportViewController.RenderSnapshotForTesting {
+ try await awaitChatRenderForTesting(
+ chatID: fixture.chatID,
+ expectedLog: reviewChatLogText(for: fixture),
+ in: transport,
+ allowIncrementalUpdate: allowIncrementalUpdate,
+ timeout: timeout,
+ matching: predicate
+ )
+}
+
+@MainActor
+func awaitChatRenderForTesting(
+ snapshot: CodexThreadSnapshot,
+ in transport: ReviewMonitorTransportViewController,
+ allowIncrementalUpdate: Bool = true,
+ timeout: Duration = .seconds(2),
+ matching predicate: (@Sendable (ReviewMonitorTransportViewController.RenderSnapshotForTesting) -> Bool)? = nil
+) async throws -> ReviewMonitorTransportViewController.RenderSnapshotForTesting {
+ try await awaitChatRenderForTesting(
+ chatID: snapshot.id,
+ expectedLog: reviewChatLogText(for: snapshot),
+ in: transport,
+ allowIncrementalUpdate: allowIncrementalUpdate,
+ timeout: timeout,
+ matching: predicate
+ )
+}
+
+@MainActor
+func awaitChatRenderForTesting(
+ chatID: CodexThreadID,
+ expectedLog: String,
+ in transport: ReviewMonitorTransportViewController,
+ allowIncrementalUpdate: Bool = true,
+ timeout: Duration = .seconds(2),
+ matching predicate: (@Sendable (ReviewMonitorTransportViewController.RenderSnapshotForTesting) -> Bool)? = nil
+) async throws -> ReviewMonitorTransportViewController.RenderSnapshotForTesting {
+ _ = allowIncrementalUpdate
+ let expectedSelection: ReviewMonitorTransportViewController.DisplayedSelectionForTesting =
+ .chat(chatID.rawValue)
+ let clock = ContinuousClock()
+ let deadline = clock.now + timeout
+ repeat {
+ let state = transport.renderedStateForTesting
+ let matchesSnapshot: Bool
+ if let predicate {
+ matchesSnapshot = predicate(state.snapshot)
+ } else {
+ matchesSnapshot = reviewChatRenderedLogMatches(state.snapshot.log, expectedLog)
+ }
+ if transport.logRenderIsIdleForTesting,
+ state.selection == expectedSelection,
+ matchesSnapshot
+ {
+ return state.snapshot
+ }
+ await Task.yield()
+ } while clock.now < deadline
+
+ let state = transport.renderedStateForTesting
+ let matchesSnapshot: Bool
+ if let predicate {
+ matchesSnapshot = predicate(state.snapshot)
+ } else {
+ matchesSnapshot = reviewChatRenderedLogMatches(state.snapshot.log, expectedLog)
+ }
+ if transport.logRenderIsIdleForTesting,
+ state.selection == expectedSelection,
+ matchesSnapshot
+ {
+ return state.snapshot
+ }
+ throw TestFailure(
+ "timed out waiting for selected chat render: "
+ + "idle=\(transport.logRenderIsIdleForTesting), "
+ + "actual=\(state), expectedSelection=\(expectedSelection), expectedLog=\(expectedLog)"
+ )
+}
+
+func reviewChatRenderedLogMatches(_ actual: String, _ expected: String) -> Bool {
+ actual == expected
+ || actual.trimmingCharacters(in: .newlines) == expected.trimmingCharacters(in: .newlines)
+}
+
+@MainActor
+func makePreviewAppServerRuntimeForTesting(
+ chat: ReviewChatFixtureForTesting.Chat,
+ cwd: String,
+ streamID: String,
+ isRunning: Bool,
+ initialSnapshot: CodexThreadSnapshot
+) -> ReviewMonitorPreviewAppServerRuntime {
+ ReviewMonitorPreviewAppServerRuntime(
+ fixtures: [
+ makePreviewChatLogFixtureForTesting(
+ chat: chat,
+ cwd: cwd,
+ streamID: streamID,
+ isRunning: isRunning,
+ initialSnapshot: initialSnapshot
+ )
+ ]
+ )
+}
+
+@MainActor
+func makePreviewMessageItemForTesting(
+ id: String,
+ text: String,
+ turnID: CodexTurnID
+) -> CodexThreadItem {
+ CodexThreadItem(
+ id: id,
+ kind: .agentMessage,
+ content: .message(.init(id: id, role: .assistant, text: text))
+ )
+}
+
+@MainActor
+func chatCommandOutputBlockIDForTesting(
+ turnID: CodexTurnID,
+ itemID: String,
+ kind: CodexThreadItem.Kind = .commandExecution
+) -> ReviewMonitorLog.BlockID {
+ // Command output panel block ids reuse CodexItem.id.rawValue, whose
+ // format is "::".
+ ReviewMonitorLog.BlockID("commandOutput:\(turnID.rawValue):\(kind.rawValue):\(itemID)")
+}
+
+@MainActor
+private func makePreviewChatLogFixtureForTesting(
+ _ fixture: ReviewChatFixtureForTesting
+) -> ReviewMonitorPreviewChatLogFixture {
+ makePreviewChatLogFixtureForTesting(fixture: fixture) { _ in
+ fixture.initialSnapshot.items
+ }
+}
+
+@MainActor
+private func makePreviewChatLogFixtureForTesting(
+ fixture: ReviewChatFixtureForTesting,
+ items makeItems: (CodexTurnID) -> [CodexThreadItem]
+) -> ReviewMonitorPreviewChatLogFixture {
+ let initialSnapshot = makeCodexThreadSnapshotForTesting(
+ chatID: fixture.chatID,
+ turns: fixture.initialSnapshot.turns ?? [],
+ items: makeItems(fixture.turnID)
+ )
+ return makePreviewChatLogFixtureForTesting(
+ chat: fixture.chat,
+ cwd: fixture.cwd,
+ streamID: fixture.streamID,
+ isRunning: fixture.isRunning,
+ initialSnapshot: initialSnapshot
+ )
+}
+
+@MainActor
+func codexThreadSnapshotForTesting(_ fixture: ReviewChatFixtureForTesting) -> CodexThreadSnapshot {
+ makeCodexThreadSnapshotForTesting(
+ chatID: fixture.chatID,
+ turns: fixture.initialSnapshot.turns ?? [],
+ items: ReviewChatLogFixtureStore.items(for: fixture.chatID, turnID: fixture.turnID)
+ )
+}
+
+@MainActor
+func makeCodexThreadSnapshotForTesting(
+ chatID: CodexThreadID,
+ turnID: CodexTurnID,
+ turnStatus: CodexTurnStatus = .completed,
+ turnErrorDescription: String? = nil,
+ items: [CodexThreadItem] = []
+) -> CodexThreadSnapshot {
+ makeCodexThreadSnapshotForTesting(
+ chatID: chatID,
+ turns: [
+ .init(
+ id: turnID,
+ status: turnStatus,
+ errorMessage: turnErrorDescription,
+ items: items
+ )
+ ]
+ )
+}
+
+@MainActor
+func makeCodexThreadSnapshotForTesting(
+ chatID: CodexThreadID,
+ turns: [CodexTurnSnapshot],
+ items: [CodexThreadItem] = []
+) -> CodexThreadSnapshot {
+ var resolvedTurns = turns
+ if items.isEmpty == false {
+ if resolvedTurns.isEmpty {
+ resolvedTurns = [CodexTurnSnapshot(id: CodexTurnID(rawValue: "\(chatID.rawValue):preview-turn"))]
+ }
+ resolvedTurns[resolvedTurns.count - 1].items = items
+ }
+ return CodexThreadSnapshot(
+ id: chatID,
+ turns: resolvedTurns
+ )
+}
+
+@MainActor
+func makePreviewChatLogFixtureForTesting(
+ chat: ReviewChatFixtureForTesting.Chat,
+ cwd: String,
+ streamID: String,
+ isRunning: Bool,
+ initialSnapshot: CodexThreadSnapshot
+) -> ReviewMonitorPreviewChatLogFixture {
+ ReviewMonitorPreviewChatLogFixture(
+ chatID: chat.id,
+ title: chat.title,
+ preview: chat.preview,
+ model: chat.model,
+ workspaceCWD: chat.workspaceCWD,
+ updatedAt: chat.updatedAt,
+ recencyAt: chat.recencyAt,
+ status: chat.status,
+ cwd: cwd,
+ streamID: streamID,
+ isRunning: isRunning,
+ initialThreadSnapshot: initialSnapshot
+ )
+}
+
+@MainActor
+private enum ReviewChatLogFixtureStore {
+ private static var entriesByChatID: [CodexThreadID: [ReviewChatLogEntryForTesting]] = [:]
+
+ static func setEntries(_ entries: [ReviewChatLogEntryForTesting], for chatID: CodexThreadID) {
+ entriesByChatID[chatID] = entries
+ }
+
+ static func replaceEntries(
+ _ entries: [ReviewChatLogEntryForTesting],
+ for chatID: CodexThreadID,
+ turnID: CodexTurnID
+ ) async {
+ setEntries(entries, for: chatID)
+ await updateRetainedPreviewSource(for: chatID, turnID: turnID)
+ }
+
+ static func append(_ entry: ReviewChatLogEntryForTesting, to chatID: CodexThreadID, turnID: CodexTurnID) async {
+ entriesByChatID[chatID, default: []].append(entry)
+ await updateRetainedPreviewSource(for: chatID, turnID: turnID)
+ }
+
+ static func items(for chatID: CodexThreadID, turnID: CodexTurnID) -> [CodexThreadItem] {
+ makeChatItems(from: entriesByChatID[chatID, default: []], turnID: turnID)
+ }
+
+ static func logText(for snapshot: CodexThreadSnapshot, chatUpdatedAt: Date?) -> String {
+ var projection = ReviewMonitorCodexChatLogProjection()
+ let document = projection.render(
+ from: snapshot,
+ chatCreatedAt: nil,
+ chatUpdatedAt: chatUpdatedAt
+ )
+ guard let document else {
+ return ""
+ }
+ let displayDocument = ReviewMonitorCommandOutputDisplayDocument.make(from: document)
+ return ReviewMonitorCommandOutputDisplayDocument.userVisibleText(from: displayDocument.text)
+ }
+
+ private static func updateRetainedPreviewSource(for chatID: CodexThreadID, turnID: CodexTurnID) async {
+ prunePreviewSupportRetainersByStore()
+ for support in runStorePreviewSupportRetainers where support.contains(chatID: chatID) {
+ await support.upsert(chatID: chatID, items: items(for: chatID, turnID: turnID))
+ }
+ }
+}
+
+@MainActor
+private final class ReviewChatLogFixtureRetainer {
+ weak var store: CodexReviewStore?
+ let runtime: ReviewMonitorPreviewAppServerRuntime
+ private var chatIDs: Set
+
+ init(store: CodexReviewStore, runtime: ReviewMonitorPreviewAppServerRuntime, chatIDs: Set) {
+ self.store = store
+ self.runtime = runtime
+ self.chatIDs = chatIDs
+ }
+
+ func contains(chatID: CodexThreadID) -> Bool {
+ chatIDs.contains(chatID)
+ }
+
+ func upsert(chatID: CodexThreadID, items: [CodexThreadItem]) async {
+ let previousItemsByID = Dictionary(
+ uniqueKeysWithValues: await runtime.snapshotForTesting(chatID: chatID)?.items.map { ($0.id, $0) } ?? []
+ )
+ for item in items {
+ guard let previousItem = previousItemsByID[item.id] else {
+ await runtime.upsertPreviewItem(id: item.id, kind: item.kind, content: item.content, to: chatID)
+ continue
+ }
+ guard previousItem != item else {
+ continue
+ }
+ if let outputDelta = previousItem.outputDeltaForTesting(to: item) {
+ await runtime.appendPreviewText(
+ outputDelta,
+ to: chatID,
+ itemID: item.id,
+ kind: item.kind,
+ content: previousItem.content
+ )
+ } else if let textDelta = previousItem.textDeltaForTesting(to: item) {
+ await runtime.appendPreviewText(
+ textDelta,
+ to: chatID,
+ itemID: item.id,
+ kind: item.kind,
+ content: previousItem.content
+ )
+ } else {
+ await runtime.upsertPreviewItem(id: item.id, kind: item.kind, content: item.content, to: chatID)
+ }
+ }
+ }
+}
+
+@MainActor
+private var runStorePreviewSupportRetainers: [ReviewChatLogFixtureRetainer] = []
+@MainActor
+private var previewSupportRetainersByStore: [ObjectIdentifier: ReviewChatLogFixtureRetainer] = [:]
+
+@MainActor
+private func makeChatItems(
+ from entries: [ReviewChatLogEntryForTesting],
+ turnID: CodexTurnID
+) -> [CodexThreadItem] {
+ var accumulated: [String: ReviewChatLogAccumulatedItem] = [:]
+ var orderedIDs: [String] = []
+ for entry in entries {
+ let itemID = entry.groupID ?? entry.id.uuidString
+ let previous = entry.replacesGroup ? nil : accumulated[itemID]
+ let next = ReviewChatLogAccumulatedItem(entry: entry, existing: previous, itemID: itemID, turnID: turnID)
+ accumulated[itemID] = next
+ if orderedIDs.contains(itemID) == false {
+ orderedIDs.append(itemID)
+ }
+ }
+ return orderedIDs.compactMap { accumulated[$0]?.snapshot }
+}
+
+private struct ReviewChatLogAccumulatedItem {
+ var snapshot: CodexThreadItem
+
+ init(
+ entry: ReviewChatLogEntryForTesting,
+ existing: ReviewChatLogAccumulatedItem?,
+ itemID: String,
+ turnID: CodexTurnID
+ ) {
+ let status = codexTurnStatus(for: entry)
+ switch entry.kind {
+ case .command:
+ snapshot = .init(
+ id: itemID,
+ kind: .commandExecution,
+ content: .command(
+ .init(
+ command: chatLogCommandText(for: entry),
+ cwd: entry.metadata?.cwd,
+ output: existing?.commandOutput ?? "",
+ exitCode: entry.metadata?.exitCode,
+ status: status
+ ))
+ )
+ case .commandOutput:
+ snapshot = .init(
+ id: itemID,
+ kind: .commandExecution,
+ content: .command(
+ .init(
+ command: entry.metadata?.command ?? existing?.commandText ?? "Command",
+ cwd: entry.metadata?.cwd ?? existing?.commandCWD,
+ output: (existing?.commandOutput ?? "") + entry.text,
+ exitCode: entry.metadata?.exitCode ?? existing?.commandExitCode,
+ status: status ?? existing?.commandStatus
+ ))
+ )
+ case .fileChange:
+ snapshot = .init(
+ id: itemID,
+ kind: .fileChange,
+ content: .fileChange(
+ .init(
+ path: entry.metadata?.title,
+ output: (existing?.plainText ?? "") + entry.text,
+ status: status
+ ))
+ )
+ case .agentMessage:
+ snapshot = .init(
+ id: itemID,
+ kind: .agentMessage,
+ content: .message(
+ .init(
+ id: itemID,
+ role: .assistant,
+ text: (existing?.messageText ?? "") + entry.text
+ ))
+ )
+ case .plan, .todoList:
+ snapshot = .init(
+ id: itemID,
+ kind: entry.kind == .todoList ? .init(rawValue: "todoList") : .plan,
+ content: .plan((existing?.plainText ?? "") + entry.text)
+ )
+ case .reasoning, .rawReasoning:
+ snapshot = .init(
+ id: itemID,
+ kind: entry.kind == .rawReasoning ? .init(rawValue: "rawReasoning") : .reasoning,
+ content: .reasoning(.init(content: (existing?.reasoningText ?? "") + entry.text))
+ )
+ case .reasoningSummary:
+ snapshot = .init(
+ id: itemID,
+ kind: .init(rawValue: "reasoningSummary"),
+ content: .reasoning(.init(summary: (existing?.reasoningText ?? "") + entry.text))
+ )
+ case .contextCompaction:
+ snapshot = .init(
+ id: itemID,
+ kind: .contextCompaction,
+ content: .contextCompaction(entry.text)
+ )
+ case .toolCall:
+ snapshot = .init(
+ id: itemID,
+ kind: .mcpToolCall,
+ content: .toolCall(.init(result: (existing?.toolResult ?? "") + entry.text, status: status))
+ )
+ case .diagnostic, .error, .progress, .event:
+ snapshot = .init(
+ id: itemID,
+ kind: .init(rawValue: entry.kind.rawValue),
+ content: .diagnostic((existing?.plainText ?? "") + entry.text)
+ )
+ }
+ }
+
+ private var command: CodexCommand? {
+ if case .command(let command) = snapshot.content {
+ return command
+ }
+ return nil
+ }
+
+ private var commandText: String? {
+ command?.command
+ }
+
+ private var commandCWD: String? {
+ command?.cwd
+ }
+
+ private var commandOutput: String {
+ command?.output ?? ""
+ }
+
+ private var commandExitCode: Int? {
+ command?.exitCode
+ }
+
+ private var commandStatus: CodexTurnStatus? {
+ command?.status
+ }
+
+ private var messageText: String? {
+ if case .message(let message) = snapshot.content {
+ return message.text
+ }
+ return nil
+ }
+
+ private var reasoningText: String? {
+ if case .reasoning(let reasoning) = snapshot.content {
+ return reasoning.text
+ }
+ return nil
+ }
+
+ private var toolResult: String? {
+ if case .toolCall(let toolCall) = snapshot.content {
+ return toolCall.result
+ }
+ return nil
+ }
+
+ private var plainText: String? {
+ snapshot.text
+ }
+}
+
+private extension CodexThreadStatus {
+ init(chatFixtureStatusForTesting status: ReviewChatFixtureStatus) {
+ switch status {
+ case .queued, .running:
+ self = .active(activeFlags: [])
+ case .succeeded, .failed, .cancelled:
+ self = .idle
+ }
+ }
+}
+
+private extension CodexTurnStatus {
+ init(chatFixtureStatusForTesting status: ReviewChatFixtureStatus) {
+ switch status {
+ case .queued, .running:
+ self = .running
+ case .succeeded:
+ self = .completed
+ case .failed:
+ self = .failed
+ case .cancelled:
+ self = .cancelled
+ }
+ }
+}
+
+private extension CodexDataPhase {
+ init(chatFixtureStatusForTesting status: ReviewChatFixtureStatus, errorMessage: String?) {
+ switch status {
+ case .queued, .running, .succeeded, .cancelled:
+ self = .loaded
+ case .failed:
+ self = .failed(errorMessage ?? "Review failed")
+ }
+ }
+}
+
+private func chatLogCommandText(for entry: ReviewChatLogEntryForTesting) -> String {
+ if let command = entry.metadata?.command, command.isEmpty == false {
+ return command
+ }
+ if entry.text.hasPrefix("$ ") {
+ return String(entry.text.dropFirst(2))
+ }
+ return entry.text
+}
+
+private func codexTurnStatus(for entry: ReviewChatLogEntryForTesting) -> CodexTurnStatus? {
+ guard let rawValue = entry.metadata?.commandStatus ?? entry.metadata?.status else {
+ return entry.kind == .command ? .running : nil
+ }
+ return CodexTurnStatus(rawValue: rawValue)
+}
+
+extension CodexThreadSnapshot {
+ var items: [CodexThreadItem] {
+ (turns ?? []).flatMap(\.items)
+ }
+}
+
+private extension CodexThreadItem {
+ func outputDeltaForTesting(to item: CodexThreadItem) -> String? {
+ guard kind == item.kind,
+ preservesOutputDeltaMetadata(for: item),
+ let previousOutput = commandOutputForTesting,
+ let nextOutput = item.commandOutputForTesting,
+ nextOutput.hasPrefix(previousOutput),
+ nextOutput.count > previousOutput.count
+ else {
+ return nil
+ }
+ return String(nextOutput.dropFirst(previousOutput.count))
+ }
+
+ func textDeltaForTesting(to item: CodexThreadItem) -> String? {
+ guard
+ kind == item.kind,
+ sameContentShapeForTesting(as: item),
+ preservesTextDeltaMetadata(for: item),
+ let previousText = text,
+ let nextText = item.text,
+ nextText.hasPrefix(previousText),
+ nextText.count > previousText.count
+ else {
+ return nil
+ }
+ return String(nextText.dropFirst(previousText.count))
+ }
+
+ private func preservesTextDeltaMetadata(for item: CodexThreadItem) -> Bool {
+ switch (content, item.content) {
+ case (.command, .command),
+ (.fileChange, .fileChange),
+ (.toolCall, .toolCall):
+ preservesOutputDeltaMetadata(for: item)
+ default:
+ true
+ }
+ }
+
+ private func preservesOutputDeltaMetadata(for item: CodexThreadItem) -> Bool {
+ switch (content, item.content) {
+ case (.command(let previous), .command(let next)):
+ previous.command == next.command
+ && previous.cwd == next.cwd
+ && previous.exitCode == next.exitCode
+ && previous.status == next.status
+ case (.fileChange(let previous), .fileChange(let next)):
+ previous.path == next.path
+ && previous.status == next.status
+ case (.toolCall(let previous), .toolCall(let next)):
+ previous.namespace == next.namespace
+ && previous.server == next.server
+ && previous.name == next.name
+ && previous.arguments == next.arguments
+ && previous.error == next.error
+ && previous.status == next.status
+ default:
+ false
+ }
+ }
+
+ private var commandOutputForTesting: String? {
+ switch content {
+ case .command(let command):
+ return command.output
+ case .fileChange(let fileChange):
+ return fileChange.output
+ case .toolCall(let toolCall):
+ return toolCall.result
+ default:
+ return nil
+ }
+ }
+
+ private func sameContentShapeForTesting(as item: CodexThreadItem) -> Bool {
+ switch (content, item.content) {
+ case (.message, .message),
+ (.plan, .plan),
+ (.reasoning, .reasoning),
+ (.command, .command),
+ (.fileChange, .fileChange),
+ (.toolCall, .toolCall),
+ (.contextCompaction, .contextCompaction),
+ (.diagnostic, .diagnostic),
+ (.log, .log),
+ (.unknown, .unknown):
+ true
+ default:
+ false
+ }
+ }
+}
diff --git a/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift
new file mode 100644
index 00000000..c0adff1e
--- /dev/null
+++ b/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift
@@ -0,0 +1,818 @@
+import CodexKit
+import CodexAppServerKitTesting
+import Foundation
+import Testing
+@_spi(Testing) @testable import CodexReviewKit
+@testable import ReviewChatLogUI
+@testable import ReviewUI
+
+@Suite("ReviewMonitor selected Codex chat detail", .serialized)
+@MainActor
+struct ReviewMonitorCodexChatDetailTests {
+ @Test func logResynchronizationCanUpdateAfterInitialBaseline() async throws {
+ let turnID = CodexTurnID(rawValue: "turn-1")
+ let chat = try await makeProjectionChat(
+ turns: [
+ .init(
+ id: turnID,
+ status: .running,
+ items: [
+ .init(
+ id: "log-1",
+ kind: .enteredReviewMode,
+ content: .log("Review started")
+ )
+ ]
+ )
+ ]
+ )
+ var projection = ReviewMonitorCodexChatLogSourceProjection()
+
+ let initialChange = projection.applyBaseline(
+ from: chat,
+ chatCreatedAt: nil,
+ chatUpdatedAt: nil
+ )
+ guard case .replaceAll = initialChange else {
+ Issue.record("Expected initial snapshot to replace the empty log")
+ return
+ }
+
+ let resynchronizedChange = projection.apply(
+ .resynchronized(reason: .refresh),
+ in: chat,
+ chatCreatedAt: nil,
+ chatUpdatedAt: nil
+ )
+ guard case .update = resynchronizedChange else {
+ Issue.record("Expected resynchronization to update the existing log incrementally")
+ return
+ }
+ #expect(resynchronizedChange?.allowsIncrementalRender == true)
+ }
+
+ @Test func selectedReviewChatRendersInitialSnapshot() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let modelContext = CodexModelContainer(appServer: runtime.server).mainContext
+ try await runtime.transport.enqueueThreadResume(.init(id: "review-thread"))
+ try await runtime.transport.enqueueThreadRead(
+ .init(
+ id: "review-thread",
+ turns: [
+ .init(
+ id: "turn-1",
+ status: .running,
+ items: [
+ .init(
+ id: "message-1",
+ kind: .agentMessage,
+ content: .message(
+ .init(
+ id: "message-1",
+ role: .assistant,
+ phase: .finalAnswer,
+ text: "Review snapshot"
+ ))
+ )
+ ]
+ )
+ ]
+ ))
+
+ let store = CodexReviewStore.makePreviewStore()
+ let uiState = ReviewMonitorUIState(auth: store.auth)
+ let transport = ReviewMonitorTransportViewController(
+ uiState: uiState,
+ modelContext: modelContext
+ )
+ transport.loadViewIfNeeded()
+
+ uiState.selection = .chat(CodexThreadID(rawValue: "review-thread"))
+
+ _ = try await awaitTransportRender(
+ transport,
+ expectedSelection: .chat("review-thread")
+ ) { snapshot in
+ snapshot.log == "Review snapshot"
+ }
+ #expect(await runtime.transport.recordedRequests(method: "thread/resume").count == 1)
+ }
+
+ @Test func switchingSelectedChatKeepsPreviousLogUntilNextBaselineRenders() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let modelContext = CodexModelContainer(appServer: runtime.server).mainContext
+ try await runtime.transport.enqueueThreadResume(.init(id: "first-thread"))
+ try await runtime.transport.enqueueThreadRead(.init(
+ id: "first-thread",
+ turns: [
+ .init(
+ id: "turn-first",
+ status: .completed,
+ items: [
+ .init(
+ id: "message-first",
+ kind: .agentMessage,
+ content: .message(.init(
+ id: "message-first",
+ role: .assistant,
+ text: "First chat log"
+ ))
+ ),
+ ]
+ ),
+ ]
+ ))
+
+ let store = CodexReviewStore.makePreviewStore()
+ let uiState = ReviewMonitorUIState(auth: store.auth)
+ let transport = ReviewMonitorTransportViewController(
+ uiState: uiState,
+ modelContext: modelContext
+ )
+ transport.loadViewIfNeeded()
+
+ selectChat(id: "first-thread", in: uiState)
+ _ = try await awaitTransportRender(
+ transport,
+ expectedSelection: .chat("first-thread")
+ ) { snapshot in
+ snapshot.log == "First chat log"
+ }
+
+ let secondReadGate = CodexAppServerTestGate()
+ await runtime.transport.holdNext(method: "thread/read", gate: secondReadGate)
+ try await runtime.transport.enqueueThreadResume(.init(id: "second-thread"))
+ try await runtime.transport.enqueueThreadRead(.init(
+ id: "second-thread",
+ turns: [
+ .init(
+ id: "turn-second",
+ status: .completed,
+ items: [
+ .init(
+ id: "message-second",
+ kind: .agentMessage,
+ content: .message(.init(
+ id: "message-second",
+ role: .assistant,
+ text: "Second chat log"
+ ))
+ ),
+ ]
+ ),
+ ]
+ ))
+
+ selectChat(id: "second-thread", in: uiState)
+ await runtime.transport.waitForRequest(method: "thread/read", count: 2)
+ try await waitForCondition {
+ transport.renderedStateForTesting.selection == .chat("second-thread")
+ }
+ #expect(transport.displayedLogForTesting == "First chat log")
+
+ await secondReadGate.open()
+ _ = try await awaitTransportRender(
+ transport,
+ expectedSelection: .chat("second-thread")
+ ) { snapshot in
+ snapshot.log == "Second chat log"
+ }
+ }
+
+ @Test func switchingSelectedChatToEmptyCurrentValueClearsAfterBaseline() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let modelContext = CodexModelContainer(appServer: runtime.server).mainContext
+ try await runtime.transport.enqueueThreadResume(.init(id: "first-thread"))
+ try await runtime.transport.enqueueThreadRead(.init(
+ id: "first-thread",
+ turns: [
+ .init(
+ id: "turn-first",
+ status: .completed,
+ items: [
+ .init(
+ id: "message-first",
+ kind: .agentMessage,
+ content: .message(.init(
+ id: "message-first",
+ role: .assistant,
+ text: "First chat log"
+ ))
+ ),
+ ]
+ ),
+ ]
+ ))
+ try await runtime.transport.enqueueThreadResume(.init(id: "empty-thread"))
+ try await runtime.transport.enqueueThreadRead(.init(id: "empty-thread", turns: []))
+
+ let store = CodexReviewStore.makePreviewStore()
+ let uiState = ReviewMonitorUIState(auth: store.auth)
+ let transport = ReviewMonitorTransportViewController(
+ uiState: uiState,
+ modelContext: modelContext
+ )
+ transport.loadViewIfNeeded()
+
+ selectChat(id: "first-thread", in: uiState)
+ _ = try await awaitTransportRender(
+ transport,
+ expectedSelection: .chat("first-thread")
+ ) { snapshot in
+ snapshot.log == "First chat log"
+ }
+
+ selectChat(id: "empty-thread", in: uiState)
+ _ = try await awaitTransportRender(
+ transport,
+ expectedSelection: .chat("empty-thread")
+ ) { snapshot in
+ snapshot.log == ""
+ }
+ }
+
+ @Test func clearingSelectionClearsDisplayedChatLog() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let modelContext = CodexModelContainer(appServer: runtime.server).mainContext
+ try await runtime.transport.enqueueThreadResume(.init(id: "review-thread"))
+ try await runtime.transport.enqueueThreadRead(.init(id: "review-thread"))
+
+ let store = CodexReviewStore.makePreviewStore()
+ let uiState = ReviewMonitorUIState(auth: store.auth)
+ let transport = ReviewMonitorTransportViewController(
+ uiState: uiState,
+ modelContext: modelContext
+ )
+ transport.loadViewIfNeeded()
+
+ selectChat(id: "review-thread", in: uiState)
+ _ = try await awaitTransportRender(
+ transport,
+ expectedSelection: .chat("review-thread")
+ ) { snapshot in
+ snapshot.log == ""
+ }
+
+ uiState.selection = nil
+ try await waitForCondition {
+ transport.renderedStateForTesting.selection == nil
+ && transport.renderedStateForTesting.snapshot.isShowingEmptyState
+ }
+ }
+
+ @Test func selectedReviewChatConnectsWhenModelSourceInstallsLater() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let modelSource = ReviewMonitorCodexModelSource()
+ try await runtime.transport.enqueueThreadResume(.init(id: "review-thread"))
+ try await runtime.transport.enqueueThreadRead(
+ .init(
+ id: "review-thread",
+ turns: [
+ .init(
+ id: "turn-1",
+ status: .running,
+ items: [
+ .init(
+ id: "message-1",
+ kind: .agentMessage,
+ content: .message(
+ .init(
+ id: "message-1",
+ role: .assistant,
+ phase: .finalAnswer,
+ text: "Late source"
+ ))
+ )
+ ]
+ )
+ ]
+ ))
+
+ let store = CodexReviewStore.makePreviewStore()
+ let uiState = ReviewMonitorUIState(auth: store.auth)
+ let transport = ReviewMonitorTransportViewController(
+ uiState: uiState,
+ codexModelSource: modelSource
+ )
+ transport.loadViewIfNeeded()
+ selectChat(id: "review-thread", in: uiState)
+
+ try await waitForCondition {
+ transport.renderedStateForTesting.selection == .chat("review-thread")
+ }
+ #expect(transport.displayedLogForTesting == "")
+
+ modelSource.install(container: CodexModelContainer(appServer: runtime.server))
+
+ _ = try await awaitTransportRender(
+ transport,
+ expectedSelection: .chat("review-thread")
+ ) { snapshot in
+ snapshot.log == "Late source"
+ }
+ }
+
+ @Test func selectedReviewChatRendersCodexChatTurnAndLiveUpdates() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let modelContext = CodexModelContainer(appServer: runtime.server).mainContext
+ try await runtime.transport.enqueueThreadResume(.init(id: "review-thread"))
+ try await runtime.transport.enqueueThreadRead(
+ .init(
+ id: "review-thread",
+ turns: [
+ .init(
+ id: "turn-1",
+ status: .running,
+ items: [
+ .init(
+ id: "message-1",
+ kind: .agentMessage,
+ content: .message(
+ .init(
+ id: "message-1",
+ role: .assistant,
+ phase: .finalAnswer,
+ text: "Chat snapshot"
+ ))
+ )
+ ]
+ )
+ ]
+ ))
+
+ let store = CodexReviewStore.makePreviewStore()
+ let uiState = ReviewMonitorUIState(auth: store.auth)
+ let transport = ReviewMonitorTransportViewController(
+ uiState: uiState,
+ modelContext: modelContext
+ )
+ transport.loadViewIfNeeded()
+
+ selectChat(id: "review-thread", in: uiState)
+
+ let initialSnapshot = try await awaitTransportRender(
+ transport,
+ expectedSelection: .chat("review-thread")
+ ) { snapshot in
+ snapshot.log.contains("Chat snapshot")
+ }
+ #expect(initialSnapshot.log.contains("Legacy fallback") == false)
+
+ try await runtime.transport.emitServerNotification(
+ method: "item/updated",
+ params: ThreadItemParams(
+ threadID: "review-thread",
+ turnID: "turn-1",
+ item: .init(
+ id: "message-1",
+ type: "agentMessage",
+ text: "Chat stream update",
+ phase: "final_answer"
+ )
+ )
+ )
+
+ let updatedSnapshot = try await awaitTransportRender(
+ transport,
+ expectedSelection: .chat("review-thread")
+ ) { snapshot in
+ snapshot.log.contains("Chat stream update")
+ }
+ #expect(updatedSnapshot.log.contains("Chat snapshot") == false)
+ #expect(updatedSnapshot.log.contains("Log fallback") == false)
+ }
+
+ @Test func codexChatTextAppendUsesAppendPath() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let modelContext = CodexModelContainer(appServer: runtime.server).mainContext
+ try await runtime.transport.enqueueThreadResume(.init(id: "review-thread"))
+ try await runtime.transport.enqueueThreadRead(
+ .init(
+ id: "review-thread",
+ turns: [
+ .init(
+ id: "turn-1",
+ status: .running,
+ items: [
+ .init(
+ id: "message-1",
+ kind: .agentMessage,
+ content: .message(
+ .init(
+ id: "message-1",
+ role: .assistant,
+ phase: .finalAnswer,
+ text: "Initial"
+ ))
+ )
+ ]
+ )
+ ]
+ ))
+
+ let store = CodexReviewStore.makePreviewStore()
+ let uiState = ReviewMonitorUIState(auth: store.auth)
+ let transport = ReviewMonitorTransportViewController(
+ uiState: uiState,
+ modelContext: modelContext
+ )
+ transport.loadViewIfNeeded()
+
+ selectChat(id: "review-thread", in: uiState)
+
+ _ = try await awaitTransportRender(
+ transport,
+ expectedSelection: .chat("review-thread")
+ ) { snapshot in
+ snapshot.log == "Initial"
+ }
+ transport.setLogReduceMotionForTesting(false)
+ let appendCount = transport.logAppendCountForTesting
+ let reloadCount = transport.logReloadCountForTesting
+
+ try await runtime.transport.emitServerNotification(
+ method: "item/updated",
+ params: ThreadItemParams(
+ threadID: "review-thread",
+ turnID: "turn-1",
+ item: .init(
+ id: "message-1",
+ type: "agentMessage",
+ text: "Initial log",
+ phase: "final_answer"
+ )
+ )
+ )
+
+ let updatedSnapshot = try await awaitTransportRender(
+ transport,
+ expectedSelection: .chat("review-thread")
+ ) { snapshot in
+ snapshot.log == "Initial log"
+ }
+ #expect(updatedSnapshot.log == "Initial log")
+ #expect(transport.logAppendCountForTesting == appendCount + 1)
+ #expect(transport.logReloadCountForTesting == reloadCount)
+ }
+
+ @Test func codexChatLogProjectionSkipsUserPromptWhenReviewModeLogExists() async throws {
+ var projection = ReviewMonitorCodexChatLogProjection()
+ let turnID = CodexTurnID(rawValue: "turn-review")
+ let turn = CodexTurnSnapshot(
+ id: turnID,
+ status: .running,
+ items: [
+ .init(
+ id: "user-message",
+ kind: .userMessage,
+ content: .message(.init(
+ id: "user-message",
+ role: .user,
+ text: "Review the current code changes."
+ ))
+ ),
+ .init(
+ id: "entered-review",
+ kind: .enteredReviewMode,
+ content: .log("Review the current code changes.")
+ ),
+ ]
+ )
+ let document = projection.render(
+ from: turn,
+ chatCreatedAt: nil,
+ chatUpdatedAt: nil
+ )
+
+ #expect(document?.text == "Review the current code changes.")
+ }
+
+ @Test func codexChatLogProjectionRendersUserMessageWithoutReviewModeLog() async throws {
+ var projection = ReviewMonitorCodexChatLogProjection()
+ let turn = CodexTurnSnapshot(
+ id: CodexTurnID(rawValue: "turn-chat"),
+ status: .running,
+ items: [
+ .init(
+ id: "user-message",
+ kind: .userMessage,
+ content: .message(.init(
+ id: "user-message",
+ role: .user,
+ text: "Explain the current diff."
+ ))
+ ),
+ ]
+ )
+ let document = projection.render(
+ from: turn,
+ chatCreatedAt: nil,
+ chatUpdatedAt: nil
+ )
+
+ #expect(document?.text == "Explain the current diff.")
+ }
+
+ @Test func codexChatLogProjectionUsesTerminalTurnStatusForRunningCommand() async throws {
+ var projection = ReviewMonitorCodexChatLogProjection()
+ let turn = CodexTurnSnapshot(
+ id: CodexTurnID(rawValue: "turn-command"),
+ status: .completed,
+ items: [
+ .init(
+ id: "command-running",
+ kind: .commandExecution,
+ content: .command(.init(
+ command: "/bin/zsh -lc",
+ output: "done",
+ status: .running,
+ startedAt: Date(timeIntervalSince1970: 4_000)
+ ))
+ ),
+ ]
+ )
+
+ let renderedDocument = projection.render(
+ from: turn,
+ chatCreatedAt: nil,
+ chatUpdatedAt: Date(timeIntervalSince1970: 4_005)
+ )
+ let sourceDocument = try #require(renderedDocument)
+ let commandBlock = try #require(sourceDocument.blocks.first { $0.kind == ReviewMonitorLog.Kind.command })
+ #expect(commandBlock.metadata?.status == CodexTurnStatus.completed.rawValue)
+ #expect(commandBlock.metadata?.commandStatus == CodexTurnStatus.completed.rawValue)
+
+ let displayDocument = ReviewMonitorCommandOutputDisplayDocument.make(
+ from: sourceDocument,
+ currentDate: Date(timeIntervalSince1970: 4_010)
+ )
+ let panel = try #require(displayDocument.commandOutputPanels.first)
+ #expect(panel.isActive == false)
+ #expect(panel.title.hasPrefix("Ran /bin/zsh -lc"))
+ #expect(panel.exitText == "Success")
+ }
+
+ @Test func codexChatLogProjectionUsesCommandExitCodeOverTerminalTurnSuccess() async throws {
+ var projection = ReviewMonitorCodexChatLogProjection()
+ let turn = CodexTurnSnapshot(
+ id: CodexTurnID(rawValue: "turn-command"),
+ status: .completed,
+ items: [
+ .init(
+ id: "command-failed",
+ kind: .commandExecution,
+ content: .command(.init(
+ command: "/bin/zsh -lc",
+ output: "error",
+ exitCode: 1,
+ status: .running,
+ startedAt: Date(timeIntervalSince1970: 4_000)
+ ))
+ ),
+ ]
+ )
+
+ let renderedDocument = projection.render(
+ from: turn,
+ chatCreatedAt: nil,
+ chatUpdatedAt: Date(timeIntervalSince1970: 4_005)
+ )
+ let sourceDocument = try #require(renderedDocument)
+ let commandBlock = try #require(sourceDocument.blocks.first { $0.kind == ReviewMonitorLog.Kind.command })
+ #expect(commandBlock.metadata?.status == CodexTurnStatus.failed.rawValue)
+ #expect(commandBlock.metadata?.commandStatus == CodexTurnStatus.failed.rawValue)
+
+ let displayDocument = ReviewMonitorCommandOutputDisplayDocument.make(
+ from: sourceDocument,
+ currentDate: Date(timeIntervalSince1970: 4_010)
+ )
+ let panel = try #require(displayDocument.commandOutputPanels.first)
+ #expect(panel.isActive == false)
+ #expect(panel.exitText == "exit 1")
+ }
+
+ @Test func codexChatStatusOnlyChangesKeepIncrementalLogUpdates() async throws {
+ var projection = ReviewMonitorCodexChatLogSourceProjection()
+ let turnID = CodexTurnID(rawValue: "turn-review")
+ let chat = try await makeProjectionChat(
+ turns: [
+ .init(
+ id: turnID,
+ status: .running,
+ items: [
+ .init(
+ id: "message-review",
+ kind: .agentMessage,
+ content: .message(.init(
+ id: "message-review",
+ role: .assistant,
+ text: "Running review"
+ ))
+ ),
+ ]
+ ),
+ ]
+ )
+
+ let initialChange = projection.applyBaseline(from: chat, chatCreatedAt: nil, chatUpdatedAt: nil)
+ let statusChange = projection.apply(
+ .turnUpdated(id: turnID),
+ in: chat,
+ chatCreatedAt: nil,
+ chatUpdatedAt: nil
+ )
+
+ #expect(initialChange?.allowsIncrementalRender == false)
+ #expect(statusChange?.allowsIncrementalRender == true)
+ #expect(statusChange?.sourceDocument?.text == "Running review")
+ }
+
+ @Test func codexChatSourceProjectionKeepsTranscriptWhenNewTurnStartsWithoutRenderableText() async throws {
+ var projection = ReviewMonitorCodexChatLogSourceProjection()
+ let firstTurnID = CodexTurnID(rawValue: "turn-review")
+ let secondTurnID = CodexTurnID(rawValue: "turn-reasoning")
+ let initialChat = try await makeProjectionChat(
+ turns: [
+ .init(
+ id: firstTurnID,
+ status: .running,
+ items: [
+ .init(
+ id: "message-review",
+ kind: .agentMessage,
+ content: .message(.init(
+ id: "message-review",
+ role: .assistant,
+ text: "Existing review log"
+ ))
+ ),
+ ]
+ ),
+ ]
+ )
+ let updatedChat = try await makeProjectionChat(
+ turns: [
+ .init(
+ id: firstTurnID,
+ status: .running,
+ items: [
+ .init(
+ id: "message-review",
+ kind: .agentMessage,
+ content: .message(.init(
+ id: "message-review",
+ role: .assistant,
+ text: "Existing review log"
+ ))
+ ),
+ ]
+ ),
+ .init(
+ id: secondTurnID,
+ status: .running,
+ items: [
+ .init(
+ id: "reasoning-empty",
+ kind: .reasoning,
+ content: .reasoning(.empty)
+ ),
+ ]
+ ),
+ ]
+ )
+
+ let initialChange = projection.applyBaseline(
+ from: initialChat,
+ chatCreatedAt: nil,
+ chatUpdatedAt: nil
+ )
+ let updatedChange = projection.apply(
+ .itemInserted(id: "reasoning-empty", turnID: secondTurnID),
+ in: updatedChat,
+ chatCreatedAt: nil,
+ chatUpdatedAt: nil
+ )
+
+ #expect(initialChange?.sourceDocument?.text == "Existing review log")
+ guard case .update(let document) = updatedChange else {
+ Issue.record("Expected the existing chat transcript to stay rendered while a new empty turn starts.")
+ return
+ }
+ #expect(document.text == "Existing review log")
+ #expect(updatedChange?.allowsIncrementalRender == true)
+ }
+
+ @Test func codexChatRendersThreadAndLiveUpdates() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let modelContext = CodexModelContainer(appServer: runtime.server).mainContext
+ try await runtime.transport.enqueueThreadResume(.init(id: "chat-thread"))
+ try await runtime.transport.enqueueThreadRead(
+ .init(
+ id: "chat-thread",
+ turns: [
+ .init(
+ id: "turn-1",
+ status: .running,
+ items: [
+ .init(
+ id: "message-1",
+ kind: .agentMessage,
+ content: .message(
+ .init(
+ id: "message-1",
+ role: .assistant,
+ phase: .finalAnswer,
+ text: "Generic chat snapshot"
+ ))
+ )
+ ]
+ )
+ ]
+ ))
+
+ let store = CodexReviewStore.makePreviewStore()
+ let uiState = ReviewMonitorUIState(auth: store.auth)
+ let transport = ReviewMonitorTransportViewController(
+ uiState: uiState,
+ modelContext: modelContext
+ )
+ transport.loadViewIfNeeded()
+
+ uiState.selection = .chat(CodexThreadID(rawValue: "chat-thread"))
+
+ _ = try await awaitTransportRender(
+ transport,
+ expectedSelection: .chat("chat-thread")
+ ) { snapshot in
+ snapshot.log.contains("Generic chat snapshot")
+ }
+ #expect(transport.renderedStateForTesting.selection == .chat("chat-thread"))
+
+ try await runtime.transport.emitServerNotification(
+ method: "item/updated",
+ params: ThreadItemParams(
+ threadID: "chat-thread",
+ turnID: "turn-1",
+ item: .init(
+ id: "message-1",
+ type: "agentMessage",
+ text: "Generic chat stream update",
+ phase: "final_answer"
+ )
+ )
+ )
+
+ let updatedSnapshot = try await awaitTransportRender(
+ transport,
+ expectedSelection: .chat("chat-thread")
+ ) { snapshot in
+ snapshot.log.contains("Generic chat stream update")
+ }
+ #expect(updatedSnapshot.log.contains("Generic chat snapshot") == false)
+ #expect(await runtime.transport.recordedRequests(method: "thread/resume").count == 1)
+ }
+
+ private func selectChat(
+ id: String,
+ in uiState: ReviewMonitorUIState
+ ) {
+ let chatID = CodexThreadID(rawValue: id)
+ uiState.selection = .chat(chatID)
+ }
+
+ private func makeProjectionChat(
+ threadID: CodexThreadID = CodexThreadID(rawValue: "review-thread"),
+ turns: [CodexTurnSnapshot]
+ ) async throws -> CodexChat {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let modelContext = CodexModelContainer(appServer: runtime.server).mainContext
+ try await runtime.transport.enqueueThreadResume(.init(id: threadID))
+ try await runtime.transport.enqueueThreadRead(
+ .init(
+ id: threadID,
+ turns: turns
+ ))
+ let chat = modelContext.model(for: threadID)
+ try await modelContext.refresh(chat)
+ return chat
+ }
+}
+
+private struct ThreadItemParams: 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 id: String
+ var type: String
+ var text: String?
+ var phase: String?
+ }
+}
diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift
new file mode 100644
index 00000000..a8dcc416
--- /dev/null
+++ b/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift
@@ -0,0 +1,167 @@
+import CodexAppServerKitTesting
+import CodexKit
+import Foundation
+import Testing
+@testable import ReviewUI
+
+@Suite("ReviewMonitor Codex selection title resolver")
+@MainActor
+struct ReviewMonitorCodexSelectionTitleResolverTests {
+ @Test func resolvesWorkspaceGroupAndChatTitlesFromLoadedCodexModels() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let context = CodexModelContainer(appServer: runtime.server).mainContext
+ let repo = try makeTitleResolverGitRepository()
+ let app = try makeTitleResolverDirectory("App", in: repo)
+ let tools = try makeTitleResolverDirectory("Tools", in: repo)
+ let appThreadID = CodexThreadID(rawValue: "thread-app")
+
+ try await runtime.transport.enqueueThreadList(
+ .init(
+ threads: [
+ .init(
+ id: appThreadID,
+ workspace: app,
+ name: "App review",
+ updatedAt: Date(timeIntervalSince1970: 3_000)
+ ),
+ .init(
+ id: "thread-tools",
+ workspace: tools,
+ name: "Tools review",
+ updatedAt: Date(timeIntervalSince1970: 2_000)
+ ),
+ ]
+ ))
+
+ try await loadReviewChats(in: context)
+ let resolver = ReviewMonitorCodexSelectionTitleResolver(modelContext: context)
+
+ let appWorkspace = try #require(context.model(for: workspaceID(for: app)))
+ let workspaceGroup = try #require(appWorkspace.workspaceGroup)
+ let appPath = app.standardizedFileURL.resolvingSymlinksInPath().path
+
+ #expect(
+ resolver.titlePresentation(for: .workspaceGroup(workspaceGroup.id))
+ == ReviewMonitorCodexSelectionTitlePresentation(
+ title: repo.lastPathComponent,
+ subtitle: "2 workspaces"
+ ))
+ #expect(
+ resolver.titlePresentation(for: .chat(appThreadID))
+ == ReviewMonitorCodexSelectionTitlePresentation(
+ title: "App review",
+ subtitle: appPath
+ ))
+ }
+
+ @Test func resolvesSingleWorkspaceGroupSubtitleFromWorkspacePath() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let context = CodexModelContainer(appServer: runtime.server).mainContext
+ let repo = try makeTitleResolverGitRepository()
+
+ try await runtime.transport.enqueueThreadList(
+ .init(
+ threads: [
+ .init(
+ id: "thread-repo",
+ workspace: repo,
+ name: "Repo review",
+ updatedAt: Date(timeIntervalSince1970: 1_000)
+ )
+ ]
+ ))
+
+ try await loadReviewChats(in: context)
+ let resolver = ReviewMonitorCodexSelectionTitleResolver(modelContext: context)
+
+ let workspace = try #require(context.model(for: workspaceID(for: repo)))
+ let workspaceGroup = try #require(workspace.workspaceGroup)
+ let repoPath = repo.standardizedFileURL.resolvingSymlinksInPath().path
+
+ #expect(
+ resolver.titlePresentation(for: .workspaceGroup(workspaceGroup.id))
+ == ReviewMonitorCodexSelectionTitlePresentation(
+ title: repo.lastPathComponent,
+ subtitle: repoPath
+ ))
+ }
+
+ @Test func resolvesUncategorizedChatButDoesNotTreatUnknownChatAsLoaded() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let context = CodexModelContainer(appServer: runtime.server).mainContext
+ let floatingThreadID = CodexThreadID(rawValue: "thread-floating")
+
+ try await runtime.transport.enqueueThreadList(
+ .init(
+ threads: [
+ .init(
+ id: floatingThreadID,
+ name: "Floating review",
+ preview: "Uncategorized preview",
+ updatedAt: Date(timeIntervalSince1970: 1_000)
+ )
+ ]
+ ))
+
+ try await loadReviewChats(in: context)
+ let resolver = ReviewMonitorCodexSelectionTitleResolver(modelContext: context)
+
+ #expect(
+ resolver.titlePresentation(for: .chat(floatingThreadID))
+ == ReviewMonitorCodexSelectionTitlePresentation(
+ title: "Floating review",
+ subtitle: ""
+ ))
+ #expect(
+ resolver.titlePresentation(for: .chat(CodexThreadID(rawValue: "thread-missing"))) == nil
+ )
+ }
+
+ @Test func returnsNilForMissingWorkspaceGroupAndEmptySelection() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let context = CodexModelContainer(appServer: runtime.server).mainContext
+
+ try await runtime.transport.enqueueThreadList(.init(threads: []))
+
+ try await loadReviewChats(in: context)
+ let resolver = ReviewMonitorCodexSelectionTitleResolver(modelContext: context)
+
+ #expect(resolver.titlePresentation(for: nil) == nil)
+ #expect(
+ resolver.titlePresentation(for: .workspaceGroup(CodexWorkspaceGroupID(rawValue: "missing"))) == nil
+ )
+ }
+}
+
+@MainActor
+private func loadReviewChats(in context: CodexModelContext) async throws {
+ let results = context.fetchedResults(
+ for: CodexFetchDescriptor(
+ predicate: .init(sourceKinds: [.subAgentReview]),
+ sortBy: [CodexSortDescriptor(\.updatedAt, order: .reverse)]
+ ),
+ sectionedBy: .workspaceGroup
+ )
+ try await results.performFetch()
+}
+
+private func workspaceID(for url: URL) -> CodexWorkspaceID {
+ CodexWorkspaceID(rawValue: url.standardizedFileURL.resolvingSymlinksInPath().path)
+}
+
+private func makeTitleResolverDirectory(_ 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 makeTitleResolverGitRepository() throws -> URL {
+ let repo = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: repo, withIntermediateDirectories: true)
+ try FileManager.default.createDirectory(
+ at: repo.appendingPathComponent(".git", isDirectory: true),
+ withIntermediateDirectories: true
+ )
+ return repo
+}
diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift
new file mode 100644
index 00000000..c227e5df
--- /dev/null
+++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift
@@ -0,0 +1,1141 @@
+import AppKit
+import CodexKit
+import CodexAppServerKitTesting
+import Foundation
+import Testing
+@_spi(Testing) @testable import CodexReviewKit
+@testable import ReviewUI
+
+@Suite("ReviewMonitor Codex sidebar results")
+@MainActor
+struct ReviewMonitorCodexSidebarResultsTests {
+ @Test func buildsFlatSidebarSectionsFromCodexFetchedResultsController() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let context = CodexModelContainer(appServer: runtime.server).mainContext
+ let repo = try makeGitRepository()
+ let app = try makeDirectory("App", in: repo)
+ let tools = try makeDirectory("Tools", in: repo)
+
+ try await runtime.transport.enqueueThreadList(
+ .init(
+ threads: [
+ .init(
+ id: "thread-app",
+ workspace: app,
+ name: "App chat",
+ updatedAt: Date(timeIntervalSince1970: 3_000)
+ ),
+ .init(
+ id: "thread-tools",
+ workspace: tools,
+ name: "Tools chat",
+ updatedAt: Date(timeIntervalSince1970: 2_000)
+ ),
+ ]
+ ))
+
+ let controller = makeCodexSidebarFetchedResultsController(context: context)
+ try await controller.performFetch()
+
+ let section = try #require(controller.sections.first)
+ let sectionWorkspaceGroupID = try #require(section.sidebarWorkspaceGroupID)
+ let appWorkspace = try #require(section.workspaces.first)
+ let appChat = try #require(section.chats(in: appWorkspace.id).first)
+ let resolvedAppPath = app.standardizedFileURL.resolvingSymlinksInPath().path
+ let resolvedToolsPath = tools.standardizedFileURL.resolvingSymlinksInPath().path
+
+ #expect(controller.sections.count == 1)
+ #expect(section.displayTitle == repo.lastPathComponent)
+ #expect(section.workspaces.map(\.url.path) == [resolvedAppPath, resolvedToolsPath])
+ #expect(section.workspaces.map(\.name) == ["App", "Tools"])
+ #expect(section.items.map(\.title) == ["App chat", "Tools chat"])
+ #expect(appChat === controller.items.first { $0.id == CodexThreadID(rawValue: "thread-app") })
+ #expect(appChat.workspace?.url.path == resolvedAppPath)
+
+ let tree = ReviewMonitorCodexSidebarOutlineTree()
+ #expect(tree.apply(sections: controller.sections).topologyChanged)
+ let outlineSection = try #require(tree.roots.first)
+ let outlineAppChat = try #require(tree.node(rowID: .chat(appChat.id)))
+
+ #expect(outlineSection.rowID == section.rowID)
+ #expect(outlineSection.item == .workspaceGroup(sectionWorkspaceGroupID))
+ #expect(outlineSection.selectionID == .workspaceGroup(sectionWorkspaceGroupID))
+ #expect(outlineSection.isExpandable)
+ #expect(
+ outlineSection.children.map(\.rowID.rawValue) == [
+ "chat:thread-app",
+ "chat:thread-tools",
+ ])
+ #expect(outlineAppChat.item == .chat(appChat.id))
+ #expect(outlineAppChat.selectionID == .chat(appChat.id))
+ #expect(
+ controller.sections.rowIDs.map(\.rawValue) == [
+ "workspaceGroup:\(sectionWorkspaceGroupID.rawValue)",
+ "chat:thread-app",
+ "chat:thread-tools",
+ ])
+ }
+
+ @Test func filteringAndPresentationOrderPreserveCodexWorkspaceSource() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let context = CodexModelContainer(appServer: runtime.server).mainContext
+ let repo = try makeGitRepository()
+ let runningThreadID = CodexThreadID(rawValue: "thread-running")
+ let idleThreadID = CodexThreadID(rawValue: "thread-idle")
+
+ try await runtime.transport.enqueueThreadList(
+ .init(
+ threads: [
+ .init(
+ id: runningThreadID,
+ workspace: repo,
+ name: "Running review",
+ updatedAt: Date(timeIntervalSince1970: 3_000),
+ status: .active(activeFlags: [])
+ ),
+ .init(
+ id: idleThreadID,
+ workspace: repo,
+ name: "Idle review",
+ updatedAt: Date(timeIntervalSince1970: 2_000),
+ status: .idle
+ ),
+ ]
+ ))
+
+ let controller = makeCodexSidebarFetchedResultsController(context: context)
+ try await controller.performFetch()
+ let sections = controller.sections
+ let originalWorkspace = try #require(sections.first?.workspaces.first)
+
+ let filteredWorkspace = try #require(sections.filtered(by: .running).first?.workspaces.first)
+ #expect(filteredWorkspace === originalWorkspace)
+ #expect(sections.filtered(by: .running).first?.chats(in: originalWorkspace.id).map(\.id) == [runningThreadID])
+ #expect(sections.filtered(by: .latestFinished).first?.chats(in: originalWorkspace.id).map(\.id) == [idleThreadID])
+
+ var order = ReviewMonitorCodexSidebarPresentationOrder()
+ _ = order.reorderChat(
+ id: idleThreadID,
+ in: sections[0].rowID,
+ currentOrder: [runningThreadID, idleThreadID],
+ before: runningThreadID
+ )
+ let orderedWorkspace = try #require(order.applying(to: sections).first?.workspaces.first)
+ #expect(orderedWorkspace === originalWorkspace)
+ #expect(order.applying(to: sections).first?.chats(in: originalWorkspace.id).map(\.id) == [
+ idleThreadID,
+ runningThreadID,
+ ])
+ }
+
+ @Test func sidebarFilterDropsSectionsWithoutMatchingChats() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let context = CodexModelContainer(appServer: runtime.server).mainContext
+ let repo = try makeGitRepository()
+
+ try await runtime.transport.enqueueThreadList(
+ .init(
+ threads: [
+ .init(
+ id: "thread-idle",
+ workspace: repo,
+ name: "Idle review",
+ updatedAt: Date(timeIntervalSince1970: 2_000),
+ status: .idle
+ )
+ ]
+ ))
+
+ let controller = makeCodexSidebarFetchedResultsController(context: context)
+ try await controller.performFetch()
+
+ #expect(controller.sections.count == 1)
+ #expect(controller.sections.filtered(by: .running).isEmpty)
+ }
+
+ @Test func defaultCodexSidebarDescriptorUsesDedicatedHomeWithoutSourceFiltering() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let context = CodexModelContainer(appServer: runtime.server).mainContext
+
+ try await runtime.transport.enqueueThreadList(.init(threads: []))
+
+ let controller = makeCodexSidebarFetchedResultsController(context: context)
+ try await controller.performFetch()
+
+ let request = try #require(await runtime.transport.recordedRequests(method: "thread/list").first)
+ let params = try request.decodeParams(ThreadListParams.self)
+ #expect(params.sourceKinds == nil)
+ }
+
+ @Test func sidebarIncludesUncategorizedChatsWithStableRowIDs() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let context = CodexModelContainer(appServer: runtime.server).mainContext
+
+ try await runtime.transport.enqueueThreadList(
+ .init(
+ threads: [
+ .init(
+ id: "thread-uncategorized",
+ name: "Floating review",
+ preview: "Uncategorized preview",
+ updatedAt: Date(timeIntervalSince1970: 4_000)
+ )
+ ]
+ ))
+
+ let controller = makeCodexSidebarFetchedResultsController(context: context)
+ try await controller.performFetch()
+
+ let section = try #require(controller.sections.first)
+ let chat = try #require(section.uncategorizedChats.first)
+
+ #expect(section.sidebarWorkspaceGroupID == nil)
+ #expect(section.workspaces.isEmpty)
+ #expect(chat.id == CodexThreadID(rawValue: "thread-uncategorized"))
+ #expect(chat.title == "Floating review")
+ #expect(chat.preview == "Uncategorized preview")
+ #expect(chat.workspace == nil)
+ #expect(
+ section.rowIDs.map(\.rawValue) == [
+ "section:unknown:unknown",
+ "chat:thread-uncategorized",
+ ])
+
+ let tree = ReviewMonitorCodexSidebarOutlineTree()
+ #expect(tree.apply(sections: controller.sections).topologyChanged)
+ let outlineSection = try #require(tree.roots.first)
+ let outlineChat = try #require(tree.node(rowID: .chat(chat.id)))
+ #expect(section.rowID == .section(.unknown("unknown")))
+ #expect(outlineSection.item == .section(.unknown("unknown")))
+ #expect(outlineSection.selectionID == nil)
+ #expect(outlineSection.children.map(\.rowID.rawValue) == ["chat:thread-uncategorized"])
+ #expect(outlineChat.selectionID == .chat(chat.id))
+ #expect(outlineChat.isExpandable == false)
+ }
+
+ @Test func sidebarOutlineTreePreservesNodeIdentityAcrossSectionUpdates() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let context = CodexModelContainer(appServer: runtime.server).mainContext
+ let repo = try makeGitRepository()
+ let threadID = CodexThreadID(rawValue: "thread-app")
+
+ try await runtime.transport.enqueueThreadList(
+ .init(
+ threads: [
+ .init(
+ id: threadID,
+ workspace: repo,
+ name: "Initial review",
+ updatedAt: Date(timeIntervalSince1970: 1_000)
+ )
+ ]
+ ))
+
+ let controller = makeCodexSidebarFetchedResultsController(context: context)
+ try await controller.performFetch()
+ let tree = ReviewMonitorCodexSidebarOutlineTree()
+
+ #expect(tree.apply(sections: controller.sections).topologyChanged)
+ let root = try #require(tree.roots.first)
+ let chatNode = try #require(tree.node(rowID: .chat(threadID)))
+
+ try await runtime.transport.enqueueThreadResume(.init(id: threadID))
+ try await runtime.transport.enqueueThreadRead(
+ .init(
+ id: threadID,
+ workspace: repo,
+ name: "Updated review",
+ updatedAt: Date(timeIntervalSince1970: 2_000)
+ ))
+ try await context.refresh(context.model(for: threadID), includeTurns: false)
+
+ #expect(tree.apply(sections: controller.sections).topologyChanged == false)
+ #expect(tree.roots.first === root)
+ #expect(tree.node(rowID: .chat(threadID)) === chatNode)
+ #expect(chatNode.item == .chat(threadID))
+ #expect(controller.sections.chat(id: threadID)?.title == "Updated review")
+ #expect(root.children.map(\.rowID) == [.chat(threadID)])
+ #expect(root.children.first === chatNode)
+ }
+
+ @Test func sidebarRunningFilterUsesThreadStatus() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let context = CodexModelContainer(appServer: runtime.server).mainContext
+ let repo = try makeGitRepository()
+ let runningThreadID = CodexThreadID(rawValue: "thread-running")
+ let idleThreadID = CodexThreadID(rawValue: "thread-idle")
+
+ try await runtime.transport.enqueueThreadList(
+ .init(
+ threads: [
+ .init(
+ id: runningThreadID,
+ workspace: repo,
+ name: "Running review",
+ updatedAt: Date(timeIntervalSince1970: 20),
+ status: .active(activeFlags: [])
+ ),
+ .init(
+ id: idleThreadID,
+ workspace: repo,
+ name: "Idle review",
+ updatedAt: Date(timeIntervalSince1970: 30),
+ status: .idle
+ ),
+ ]
+ ))
+
+ let controller = makeCodexSidebarFetchedResultsController(context: context)
+ try await controller.performFetch()
+ let section = try #require(controller.sections.filtered(by: .running).first)
+ let workspace = try #require(section.workspaces.first)
+
+ #expect(section.chats(in: workspace.id).map(\.id) == [runningThreadID])
+ #expect(section.chats(in: workspace.id).contains { $0.id == idleThreadID } == false)
+ }
+
+ @Test func sidebarViewControllerInstallsCodexSidebarFetchedResultsControllerFromModelContext() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let context = CodexModelContainer(appServer: runtime.server).mainContext
+ let repo = try makeGitRepository()
+ let threadID = CodexThreadID(rawValue: "thread-app")
+
+ try await runtime.transport.enqueueThreadList(
+ .init(
+ threads: [
+ .init(
+ id: threadID,
+ workspace: repo,
+ name: "App review",
+ updatedAt: Date(timeIntervalSince1970: 5_000)
+ )
+ ]
+ ))
+
+ let store = CodexReviewStore.makePreviewStore()
+ store.loadForTesting(serverState: .running)
+ let uiState = ReviewMonitorUIState(auth: store.auth)
+ let viewController = ReviewMonitorSplitViewController(
+ store: store,
+ uiState: uiState,
+ modelContext: context
+ )
+ viewController.loadViewIfNeeded()
+
+ let sidebar = viewController.sidebarViewControllerForTesting
+ try await waitForCondition {
+ sidebar.codexSidebarSectionsForTesting.first?.chat(id: threadID)?.title == "App review"
+ }
+ try await waitForCondition {
+ sidebar.codexSidebarRootTitlesForTesting == [repo.lastPathComponent]
+ && sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(threadID)) == "App review"
+ }
+ #expect(
+ sidebar.displayedCodexSidebarTitlesForTesting == [
+ repo.lastPathComponent,
+ "App review",
+ ])
+ #expect(sidebar.reviewChatRowHeightForTesting(threadID) == sidebar.expectedReviewChatRowRectHeightForTesting)
+
+ sidebar.selectCodexSidebarRowForTesting(rowID: .chat(threadID))
+ guard case .chat(let selectedChatID) = uiState.selection else {
+ Issue.record("Expected selecting a Codex sidebar chat row to select the chat.")
+ return
+ }
+ #expect(selectedChatID == threadID)
+ #expect(sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(threadID)) == "App review")
+ }
+
+ @Test func codexSidebarSelectionDoesNotFallBackToReviewRunRows() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let context = CodexModelContainer(appServer: runtime.server).mainContext
+ let repo = try makeGitRepository()
+ let visibleThreadID = CodexThreadID(rawValue: "thread-app")
+ let hiddenRunThreadID = CodexThreadID(rawValue: "run-backed-review-thread")
+
+ try await runtime.transport.enqueueThreadList(
+ .init(
+ threads: [
+ .init(
+ id: visibleThreadID,
+ workspace: repo,
+ name: "App review",
+ updatedAt: Date(timeIntervalSince1970: 5_000)
+ )
+ ]
+ ))
+
+ let runBackedRecord = ReviewRunRecord.makeForTesting(
+ id: "run-backed-record",
+ cwd: repo.path,
+ targetSummary: "Run-backed review row",
+ threadID: hiddenRunThreadID.rawValue,
+ turnID: "run-backed-turn",
+ status: .running,
+ startedAt: Date(timeIntervalSince1970: 4_000),
+ summary: "Running review."
+ )
+ let store = CodexReviewStore.makePreviewStore()
+ store.loadReviewCancellationStateForTesting(
+ serverState: .running,
+ reviewRuns: [runBackedRecord]
+ )
+ let uiState = ReviewMonitorUIState(auth: store.auth)
+ let viewController = ReviewMonitorSplitViewController(
+ store: store,
+ uiState: uiState,
+ modelContext: context
+ )
+ viewController.loadViewIfNeeded()
+
+ let sidebar = viewController.sidebarViewControllerForTesting
+ try await waitForCondition {
+ sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(visibleThreadID)) == "App review"
+ }
+ #expect(sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(hiddenRunThreadID)) == nil)
+
+ uiState.selection = .chat(hiddenRunThreadID)
+
+ try await waitForCondition {
+ sidebar.nativeSelectedReviewChatIDForTesting == nil
+ }
+ #expect(uiState.selection == .chat(hiddenRunThreadID))
+ #expect(sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(hiddenRunThreadID)) == nil)
+ }
+
+ @Test func sidebarViewControllerTracksCodexSidebarFetchResultChanges() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let context = CodexModelContainer(appServer: runtime.server).mainContext
+ let repo = try makeGitRepository()
+ let threadID = CodexThreadID(rawValue: "thread-app")
+
+ try await runtime.transport.enqueueThreadList(.init(threads: []))
+
+ let store = CodexReviewStore.makePreviewStore()
+ store.loadForTesting(serverState: .running)
+ let viewController = ReviewMonitorSplitViewController(
+ store: store,
+ uiState: ReviewMonitorUIState(auth: store.auth),
+ modelContext: context
+ )
+ viewController.loadViewIfNeeded()
+ let sidebar = viewController.sidebarViewControllerForTesting
+
+ try await waitForCondition {
+ sidebar.isShowingEmptyStateForTesting
+ }
+
+ try await runtime.transport.enqueueThreadList(
+ .init(
+ threads: [
+ .init(
+ id: threadID,
+ workspace: repo,
+ name: "App review",
+ updatedAt: Date(timeIntervalSince1970: 5_000)
+ )
+ ]
+ ))
+ try await sidebar.refreshCodexSidebarForTesting()
+
+ try await waitForCondition {
+ sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(threadID)) == "App review"
+ }
+ }
+
+ @Test func sidebarRefreshOmittingSelectedRegisteredChatPreservesSelectionAndDetail() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let context = CodexModelContainer(appServer: runtime.server).mainContext
+ let repo = try makeGitRepository()
+ let selectedThreadID = CodexThreadID(rawValue: "thread-selected")
+ let remainingThreadID = CodexThreadID(rawValue: "thread-remaining")
+
+ try await runtime.transport.enqueueThreadList(
+ .init(
+ threads: [
+ .init(
+ id: selectedThreadID,
+ workspace: repo,
+ name: "Selected review",
+ updatedAt: Date(timeIntervalSince1970: 5_000),
+ status: .active(activeFlags: [])
+ ),
+ .init(
+ id: remainingThreadID,
+ workspace: repo,
+ name: "Remaining review",
+ updatedAt: Date(timeIntervalSince1970: 4_000),
+ status: .idle
+ ),
+ ]
+ ))
+
+ let store = CodexReviewStore.makePreviewStore()
+ store.loadForTesting(serverState: .running)
+ let uiState = ReviewMonitorUIState(auth: store.auth)
+ let viewController = ReviewMonitorSplitViewController(
+ store: store,
+ uiState: uiState,
+ modelContext: context
+ )
+ let window = NSWindow(contentViewController: viewController)
+ defer { window.close() }
+ window.setContentSize(NSSize(width: 900, height: 600))
+ viewController.loadViewIfNeeded()
+ viewController.view.layoutSubtreeIfNeeded()
+
+ let sidebar = viewController.sidebarViewControllerForTesting
+ let transport = viewController.transportViewControllerForTesting
+ try await waitForCondition {
+ sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(selectedThreadID)) == "Selected review"
+ && sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(remainingThreadID)) == "Remaining review"
+ }
+ let selectedChat = try #require(context.registeredModel(for: selectedThreadID))
+ #expect(selectedChat.isArchived == false)
+
+ sidebar.selectCodexSidebarRowForTesting(rowID: .chat(selectedThreadID))
+ try await waitForCondition {
+ sidebar.selectedReviewChatIDForTesting == selectedThreadID
+ && sidebar.nativeSelectedReviewChatIDForTesting == selectedThreadID
+ && transport.renderedStateForTesting.selection == .chat(selectedThreadID.rawValue)
+ && transport.renderedStateForTesting.snapshot.isShowingEmptyState == false
+ }
+
+ try await runtime.transport.enqueueThreadList(
+ .init(
+ threads: [
+ .init(
+ id: remainingThreadID,
+ workspace: repo,
+ name: "Remaining review",
+ updatedAt: Date(timeIntervalSince1970: 6_000),
+ status: .idle
+ )
+ ]
+ ))
+ try await sidebar.refreshCodexSidebarForTesting()
+
+ try await waitForCondition {
+ sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(selectedThreadID)) == "Selected review"
+ && sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(remainingThreadID)) == "Remaining review"
+ && sidebar.selectedReviewChatIDForTesting == selectedThreadID
+ && sidebar.nativeSelectedReviewChatIDForTesting == selectedThreadID
+ && transport.renderedStateForTesting.selection == .chat(selectedThreadID.rawValue)
+ && transport.renderedStateForTesting.snapshot.isShowingEmptyState == false
+ }
+ #expect(context.registeredModel(for: selectedThreadID) === selectedChat)
+ #expect(selectedChat.isArchived == false)
+ }
+
+ @Test func sidebarViewControllerShowsEmptyStateWhenFilterHasNoMatches() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let context = CodexModelContainer(appServer: runtime.server).mainContext
+ let repo = try makeGitRepository()
+
+ try await runtime.transport.enqueueThreadList(
+ .init(
+ threads: [
+ .init(
+ id: "thread-idle",
+ workspace: repo,
+ name: "Idle review",
+ updatedAt: Date(timeIntervalSince1970: 5_000),
+ status: .idle
+ )
+ ]
+ ))
+
+ let store = CodexReviewStore.makePreviewStore()
+ store.loadForTesting(serverState: .running)
+ let uiState = ReviewMonitorUIState(auth: store.auth, sidebarReviewChatFilter: .running)
+ let viewController = ReviewMonitorSplitViewController(
+ store: store,
+ uiState: uiState,
+ modelContext: context
+ )
+ viewController.loadViewIfNeeded()
+
+ let sidebar = viewController.sidebarViewControllerForTesting
+ try await waitForCondition {
+ sidebar.isShowingEmptyStateForTesting
+ }
+ #expect(sidebar.codexSidebarRootTitlesForTesting.isEmpty)
+ }
+
+ @Test func sidebarViewControllerPreservesSelectionAndAvoidsFullReloadWhenCodexChatContentChanges() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let context = CodexModelContainer(appServer: runtime.server).mainContext
+ let repo = try makeGitRepository()
+ let threadID = CodexThreadID(rawValue: "thread-app")
+
+ try await runtime.transport.enqueueThreadList(
+ .init(
+ threads: [
+ .init(
+ id: threadID,
+ workspace: repo,
+ name: "App review",
+ updatedAt: Date(timeIntervalSince1970: 5_000)
+ )
+ ]
+ ))
+
+ let store = CodexReviewStore.makePreviewStore()
+ store.loadForTesting(serverState: .running)
+ let uiState = ReviewMonitorUIState(auth: store.auth)
+ let viewController = ReviewMonitorSplitViewController(
+ store: store,
+ uiState: uiState,
+ modelContext: context
+ )
+ let window = NSWindow(contentViewController: viewController)
+ defer { window.close() }
+ viewController.loadViewIfNeeded()
+
+ let sidebar = viewController.sidebarViewControllerForTesting
+ try await waitForCondition(timeout: .milliseconds(500)) {
+ sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(threadID)) == "App review"
+ }
+ try await runtime.transport.enqueueThreadResume(
+ .init(
+ id: threadID,
+ workspace: repo,
+ name: "App review",
+ updatedAt: Date(timeIntervalSince1970: 5_000)
+ ))
+ try await runtime.transport.enqueueThreadRead(
+ .init(
+ id: threadID,
+ workspace: repo,
+ name: "App review",
+ updatedAt: Date(timeIntervalSince1970: 5_000)
+ ))
+ sidebar.selectCodexSidebarRowForTesting(rowID: .chat(threadID))
+ #expect(uiState.selectionID == .chat(threadID))
+ try await waitForCondition {
+ window.title == "App review"
+ }
+ let fullReloadCountBeforeContentUpdate = sidebar.sidebarFullReloadCountForTesting
+ let chat = context.model(for: threadID)
+ let chatIdentityBeforeContentUpdate = ObjectIdentifier(chat)
+ try await runtime.transport.enqueueThreadResume(.init(id: threadID))
+ try await runtime.transport.enqueueThreadRead(
+ .init(
+ id: threadID,
+ workspace: repo,
+ name: "App review renamed",
+ updatedAt: Date(timeIntervalSince1970: 6_000)
+ ))
+ try await context.refresh(chat, includeTurns: false)
+
+ try await waitForCondition {
+ sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(threadID)) == "App review renamed"
+ }
+ #expect(uiState.selectionID == .chat(threadID))
+ try await waitForCondition {
+ window.title == "App review renamed"
+ }
+ #expect(ObjectIdentifier(context.model(for: threadID)) == chatIdentityBeforeContentUpdate)
+ #expect(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeContentUpdate)
+ #expect(
+ sidebar.displayedCodexSidebarTitlesForTesting == [
+ repo.lastPathComponent,
+ "App review renamed",
+ ])
+ }
+
+ @Test func sidebarViewControllerReordersCodexWorkspaceGroupsLocally() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let context = CodexModelContainer(appServer: runtime.server).mainContext
+ let firstRepo = try makeGitRepository()
+ let secondRepo = try makeGitRepository()
+
+ try await runtime.transport.enqueueThreadList(
+ .init(
+ threads: [
+ .init(
+ id: "thread-first-repo",
+ workspace: firstRepo,
+ name: "First repo review",
+ updatedAt: Date(timeIntervalSince1970: 5_000)
+ ),
+ .init(
+ id: "thread-second-repo",
+ workspace: secondRepo,
+ name: "Second repo review",
+ updatedAt: Date(timeIntervalSince1970: 4_000)
+ ),
+ ]
+ ))
+
+ let store = CodexReviewStore.makePreviewStore()
+ store.loadForTesting(serverState: .running)
+ let viewController = ReviewMonitorSplitViewController(
+ store: store,
+ uiState: ReviewMonitorUIState(auth: store.auth),
+ modelContext: context
+ )
+ viewController.loadViewIfNeeded()
+
+ let sidebar = viewController.sidebarViewControllerForTesting
+ try await waitForCondition {
+ sidebar.codexSidebarRootTitlesForTesting == [
+ firstRepo.lastPathComponent,
+ secondRepo.lastPathComponent,
+ ]
+ }
+ let sections = sidebar.codexSidebarSectionsForTesting
+ let firstSection = try #require(sections.first)
+ let secondSection = try #require(sections.dropFirst().first)
+ let firstWorkspaceGroupID = try #require(firstSection.sidebarWorkspaceGroupID)
+ let secondWorkspaceGroupID = try #require(secondSection.sidebarWorkspaceGroupID)
+ let fullReloadCountBeforeReorder = sidebar.sidebarFullReloadCountForTesting
+
+ #expect(sidebar.codexSidebarCanStartDragForTesting(rowID: secondSection.rowID))
+ #expect(sidebar.performCodexWorkspaceGroupDropForTesting(id: secondWorkspaceGroupID, toIndex: 0))
+ #expect(
+ sidebar.codexSidebarRootTitlesForTesting == [
+ secondSection.displayTitle,
+ firstSection.displayTitle,
+ ])
+ #expect(
+ sidebar.codexSidebarSectionsForTesting.compactMap(\.sidebarWorkspaceGroupID) == [
+ firstWorkspaceGroupID,
+ secondWorkspaceGroupID,
+ ])
+ #expect(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeReorder)
+ }
+
+ @Test func sidebarViewControllerRejectsWorkspaceGroupDropsAcrossSectionRows() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let context = CodexModelContainer(appServer: runtime.server).mainContext
+ let leadingRepo = try makeGitRepository()
+ let firstRepo = try makeGitRepository()
+ let secondRepo = try makeGitRepository()
+
+ try await runtime.transport.enqueueThreadList(
+ .init(
+ threads: [
+ .init(
+ id: "thread-leading-repo",
+ workspace: leadingRepo,
+ name: "Leading repo review",
+ updatedAt: Date(timeIntervalSince1970: 7_000)
+ ),
+ .init(
+ id: "thread-uncategorized",
+ name: "Uncategorized review",
+ updatedAt: Date(timeIntervalSince1970: 6_000)
+ ),
+ .init(
+ id: "thread-first-repo",
+ workspace: firstRepo,
+ name: "First repo review",
+ updatedAt: Date(timeIntervalSince1970: 5_000)
+ ),
+ .init(
+ id: "thread-second-repo",
+ workspace: secondRepo,
+ name: "Second repo review",
+ updatedAt: Date(timeIntervalSince1970: 4_000)
+ ),
+ ]
+ ))
+
+ let store = CodexReviewStore.makePreviewStore()
+ store.loadForTesting(serverState: .running)
+ let viewController = ReviewMonitorSplitViewController(
+ store: store,
+ uiState: ReviewMonitorUIState(auth: store.auth),
+ modelContext: context
+ )
+ viewController.loadViewIfNeeded()
+
+ let sidebar = viewController.sidebarViewControllerForTesting
+ try await waitForCondition {
+ sidebar.codexSidebarRootTitlesForTesting == [
+ leadingRepo.lastPathComponent,
+ "Unknown",
+ firstRepo.lastPathComponent,
+ secondRepo.lastPathComponent,
+ ]
+ }
+
+ let sections = sidebar.codexSidebarSectionsForTesting
+ let sectionRow = try #require(sections.first { $0.sidebarWorkspaceGroupID == nil })
+ let workspaceGroupSections = sections.filter { $0.sidebarWorkspaceGroupID != nil }
+ let leadingSection = try #require(workspaceGroupSections.first)
+ let firstSection = try #require(workspaceGroupSections.dropFirst().first)
+ let secondSection = try #require(workspaceGroupSections.dropFirst(2).first)
+ let leadingWorkspaceGroupID = try #require(leadingSection.sidebarWorkspaceGroupID)
+ let firstWorkspaceGroupID = try #require(firstSection.sidebarWorkspaceGroupID)
+ let secondWorkspaceGroupID = try #require(secondSection.sidebarWorkspaceGroupID)
+
+ #expect(sidebar.codexSidebarCanStartDragForTesting(rowID: sectionRow.rowID) == false)
+ #expect(sidebar.performCodexWorkspaceGroupDropForTesting(id: firstWorkspaceGroupID, toIndex: 1) == false)
+ #expect(sidebar.performCodexWorkspaceGroupDropForTesting(id: secondWorkspaceGroupID, toIndex: 2))
+ #expect(
+ sidebar.codexSidebarRootTitlesForTesting == [
+ leadingRepo.lastPathComponent,
+ "Unknown",
+ secondRepo.lastPathComponent,
+ firstRepo.lastPathComponent,
+ ])
+ #expect(sidebar.performCodexWorkspaceGroupDropForTesting(id: leadingWorkspaceGroupID, toIndex: 4) == false)
+ }
+
+ @Test func sidebarViewControllerReordersWorkspaceGroupsAcrossFilteredOutSectionRows() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let context = CodexModelContainer(appServer: runtime.server).mainContext
+ let leadingRepo = try makeGitRepository()
+ let firstRepo = try makeGitRepository()
+ let secondRepo = try makeGitRepository()
+
+ try await runtime.transport.enqueueThreadList(
+ .init(
+ threads: [
+ .init(
+ id: "thread-leading-repo",
+ workspace: leadingRepo,
+ name: "Leading repo review",
+ updatedAt: Date(timeIntervalSince1970: 7_000),
+ status: .active(activeFlags: [])
+ ),
+ .init(
+ id: "thread-uncategorized",
+ name: "Uncategorized review",
+ updatedAt: Date(timeIntervalSince1970: 6_000),
+ status: .idle
+ ),
+ .init(
+ id: "thread-first-repo",
+ workspace: firstRepo,
+ name: "First repo review",
+ updatedAt: Date(timeIntervalSince1970: 5_000),
+ status: .active(activeFlags: [])
+ ),
+ .init(
+ id: "thread-second-repo",
+ workspace: secondRepo,
+ name: "Second repo review",
+ updatedAt: Date(timeIntervalSince1970: 4_000),
+ status: .active(activeFlags: [])
+ ),
+ ]
+ ))
+
+ let store = CodexReviewStore.makePreviewStore()
+ store.loadForTesting(serverState: .running)
+ let uiState = ReviewMonitorUIState(auth: store.auth, sidebarReviewChatFilter: .running)
+ let viewController = ReviewMonitorSplitViewController(
+ store: store,
+ uiState: uiState,
+ modelContext: context
+ )
+ viewController.loadViewIfNeeded()
+
+ let sidebar = viewController.sidebarViewControllerForTesting
+ try await waitForCondition {
+ sidebar.codexSidebarRootTitlesForTesting == [
+ leadingRepo.lastPathComponent,
+ firstRepo.lastPathComponent,
+ secondRepo.lastPathComponent,
+ ]
+ }
+
+ let sections = sidebar.codexSidebarSectionsForTesting
+ let secondSection = try #require(
+ sections.first { $0.displayTitle == secondRepo.lastPathComponent }
+ )
+ let secondWorkspaceGroupID = try #require(secondSection.sidebarWorkspaceGroupID)
+
+ #expect(sidebar.performCodexWorkspaceGroupDropForTesting(id: secondWorkspaceGroupID, toIndex: 0))
+ #expect(
+ sidebar.codexSidebarRootTitlesForTesting == [
+ secondRepo.lastPathComponent,
+ leadingRepo.lastPathComponent,
+ firstRepo.lastPathComponent,
+ ])
+ }
+
+ @Test func sidebarViewControllerReordersCodexChatsLocallyWithinContainer() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let context = CodexModelContainer(appServer: runtime.server).mainContext
+ let repo = try makeGitRepository()
+ let firstThreadID = CodexThreadID(rawValue: "thread-first")
+ let secondThreadID = CodexThreadID(rawValue: "thread-second")
+
+ try await runtime.transport.enqueueThreadList(
+ .init(
+ threads: [
+ .init(
+ id: firstThreadID,
+ workspace: repo,
+ name: "First review",
+ updatedAt: Date(timeIntervalSince1970: 5_000)
+ ),
+ .init(
+ id: secondThreadID,
+ workspace: repo,
+ name: "Second review",
+ updatedAt: Date(timeIntervalSince1970: 4_000)
+ ),
+ ]
+ ))
+
+ let store = CodexReviewStore.makePreviewStore()
+ store.loadForTesting(serverState: .running)
+ let viewController = ReviewMonitorSplitViewController(
+ store: store,
+ uiState: ReviewMonitorUIState(auth: store.auth),
+ modelContext: context
+ )
+ viewController.loadViewIfNeeded()
+
+ let sidebar = viewController.sidebarViewControllerForTesting
+ try await waitForCondition {
+ sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(secondThreadID)) == "Second review"
+ }
+ let section = try #require(sidebar.codexSidebarSectionsForTesting.first)
+ let container = section.rowID
+ let fullReloadCountBeforeReorder = sidebar.sidebarFullReloadCountForTesting
+
+ #expect(sidebar.displayedCodexChatIDsForTesting(container: container) == [firstThreadID, secondThreadID])
+ #expect(sidebar.codexSidebarCanStartDragForTesting(rowID: .chat(secondThreadID)))
+ #expect(sidebar.performCodexChatDropForTesting(id: secondThreadID, container: container, childIndex: 0))
+ #expect(sidebar.displayedCodexChatIDsForTesting(container: container) == [secondThreadID, firstThreadID])
+ #expect(section.items.map(\.id) == [firstThreadID, secondThreadID])
+ #expect(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeReorder)
+ }
+
+ @Test func sidebarViewControllerDoesNotReloadCodexOutlineWhenSelectionChanges() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let context = CodexModelContainer(appServer: runtime.server).mainContext
+ let repo = try makeGitRepository()
+ let firstThreadID = CodexThreadID(rawValue: "thread-first")
+ let secondThreadID = CodexThreadID(rawValue: "thread-second")
+
+ try await runtime.transport.enqueueThreadList(
+ .init(
+ threads: [
+ .init(
+ id: firstThreadID,
+ workspace: repo,
+ name: "First review",
+ updatedAt: Date(timeIntervalSince1970: 5_000)
+ ),
+ .init(
+ id: secondThreadID,
+ workspace: repo,
+ name: "Second review",
+ updatedAt: Date(timeIntervalSince1970: 4_000)
+ ),
+ ]
+ ))
+
+ let store = CodexReviewStore.makePreviewStore()
+ store.loadForTesting(serverState: .running)
+ let viewController = ReviewMonitorSplitViewController(
+ store: store,
+ uiState: ReviewMonitorUIState(auth: store.auth),
+ modelContext: context
+ )
+ viewController.loadViewIfNeeded()
+ let sidebar = viewController.sidebarViewControllerForTesting
+
+ try await waitForCondition {
+ sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(firstThreadID)) == "First review"
+ && sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(secondThreadID)) == "Second review"
+ }
+ let reloadCountAfterInitialFetch = sidebar.sidebarFullReloadCountForTesting
+
+ sidebar.selectCodexSidebarRowForTesting(rowID: .chat(firstThreadID))
+ try await waitForCondition {
+ sidebar.selectedReviewChatIDForTesting == firstThreadID
+ }
+ sidebar.selectCodexSidebarRowForTesting(rowID: .chat(secondThreadID))
+ try await waitForCondition {
+ sidebar.selectedReviewChatIDForTesting == secondThreadID
+ }
+
+ #expect(sidebar.sidebarFullReloadCountForTesting == reloadCountAfterInitialFetch)
+ }
+
+ @Test func sidebarIgnoresProgrammaticNativeSelectionChanges() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let context = CodexModelContainer(appServer: runtime.server).mainContext
+ let repo = try makeGitRepository()
+ let activeThreadID = CodexThreadID(rawValue: "thread-active")
+ let previousThreadID = CodexThreadID(rawValue: "thread-previous")
+
+ try await runtime.transport.enqueueThreadList(
+ .init(
+ threads: [
+ .init(
+ id: activeThreadID,
+ workspace: repo,
+ name: "Active review",
+ updatedAt: Date(timeIntervalSince1970: 5_000),
+ status: .active(activeFlags: [])
+ ),
+ .init(
+ id: previousThreadID,
+ workspace: repo,
+ name: "Previous review",
+ updatedAt: Date(timeIntervalSince1970: 4_000),
+ status: .idle
+ ),
+ ]
+ ))
+
+ let store = CodexReviewStore.makePreviewStore()
+ store.loadForTesting(serverState: .running)
+ let uiState = ReviewMonitorUIState(auth: store.auth)
+ let viewController = ReviewMonitorSplitViewController(
+ store: store,
+ uiState: uiState,
+ modelContext: context
+ )
+ let window = NSWindow(contentViewController: viewController)
+ defer { window.close() }
+ viewController.loadViewIfNeeded()
+
+ let sidebar = viewController.sidebarViewControllerForTesting
+ let transport = viewController.transportViewControllerForTesting
+ try await waitForCondition {
+ sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(activeThreadID)) == "Active review"
+ && sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(previousThreadID)) == "Previous review"
+ }
+
+ sidebar.selectCodexSidebarRowForTesting(rowID: .chat(activeThreadID))
+ try await waitForCondition {
+ sidebar.selectedReviewChatIDForTesting == activeThreadID
+ && sidebar.nativeSelectedReviewChatIDForTesting == activeThreadID
+ && transport.renderedStateForTesting.selection == .chat(activeThreadID.rawValue)
+ }
+
+ sidebar.selectCodexSidebarRowProgrammaticallyForTesting(rowID: .chat(previousThreadID))
+
+ try await waitForCondition {
+ sidebar.selectedReviewChatIDForTesting == activeThreadID
+ && sidebar.nativeSelectedReviewChatIDForTesting == activeThreadID
+ && transport.renderedStateForTesting.selection == .chat(activeThreadID.rawValue)
+ }
+ }
+
+ @Test func sidebarViewControllerKeepsWorkspaceGroupOrderWhenSelectedChatRefreshesUpdatedAt() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let context = CodexModelContainer(appServer: runtime.server).mainContext
+ let firstRepo = try makeGitRepository()
+ let secondRepo = try makeGitRepository()
+ let firstThreadID = CodexThreadID(rawValue: "thread-first-repo")
+ let secondThreadID = CodexThreadID(rawValue: "thread-second-repo")
+ let firstRecencyAt = Date(timeIntervalSince1970: 5_000)
+ let secondRecencyAt = Date(timeIntervalSince1970: 4_000)
+
+ try await runtime.transport.enqueueThreadList(
+ .init(
+ threads: [
+ .init(
+ id: firstThreadID,
+ workspace: firstRepo,
+ name: "First repo review",
+ updatedAt: firstRecencyAt,
+ recencyAt: firstRecencyAt
+ ),
+ .init(
+ id: secondThreadID,
+ workspace: secondRepo,
+ name: "Second repo review",
+ updatedAt: secondRecencyAt,
+ recencyAt: secondRecencyAt
+ ),
+ ]
+ ))
+
+ let store = CodexReviewStore.makePreviewStore()
+ store.loadForTesting(serverState: .running)
+ let uiState = ReviewMonitorUIState(auth: store.auth)
+ let viewController = ReviewMonitorSplitViewController(
+ store: store,
+ uiState: uiState,
+ modelContext: context
+ )
+ let window = NSWindow(contentViewController: viewController)
+ defer { window.close() }
+ viewController.loadViewIfNeeded()
+ let sidebar = viewController.sidebarViewControllerForTesting
+
+ try await waitForCondition {
+ sidebar.codexSidebarRootTitlesForTesting == [
+ firstRepo.lastPathComponent,
+ secondRepo.lastPathComponent,
+ ]
+ }
+ let reloadCountAfterInitialFetch = sidebar.sidebarFullReloadCountForTesting
+
+ try await runtime.transport.enqueueThreadResume(
+ .init(
+ id: secondThreadID,
+ workspace: secondRepo,
+ name: "Second repo review",
+ updatedAt: Date(timeIntervalSince1970: 9_000),
+ recencyAt: secondRecencyAt
+ ))
+ try await runtime.transport.enqueueThreadRead(
+ .init(
+ id: secondThreadID,
+ workspace: secondRepo,
+ name: "Second repo review",
+ updatedAt: Date(timeIntervalSince1970: 9_000),
+ recencyAt: secondRecencyAt
+ ))
+ sidebar.selectCodexSidebarRowForTesting(rowID: .chat(secondThreadID))
+
+ try await waitForCondition {
+ window.title == "Second repo review"
+ && sidebar.selectedReviewChatIDForTesting == secondThreadID
+ }
+ try await waitForCondition {
+ context.model(for: secondThreadID).updatedAt == Date(timeIntervalSince1970: 9_000)
+ }
+
+ #expect(
+ sidebar.codexSidebarRootTitlesForTesting == [
+ firstRepo.lastPathComponent,
+ secondRepo.lastPathComponent,
+ ])
+ #expect(sidebar.sidebarFullReloadCountForTesting == reloadCountAfterInitialFetch)
+ }
+}
+
+private struct ThreadListParams: Decodable {
+ var sourceKinds: [String]?
+}
+
+@MainActor
+private func makeCodexSidebarFetchedResultsController(
+ context: CodexModelContext
+) -> CodexFetchedResultsController {
+ context.fetchedResultsController(
+ for: ReviewMonitorSidebarViewController.defaultCodexSidebarDescriptor,
+ sectionedBy: .workspaceGroup
+ )
+}
+
+private func makeDirectory(_ 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 makeGitRepository() throws -> URL {
+ let repo = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: repo, withIntermediateDirectories: true)
+ try FileManager.default.createDirectory(
+ at: repo.appendingPathComponent(".git", isDirectory: true),
+ withIntermediateDirectories: true
+ )
+ return repo
+}
diff --git a/Tests/ReviewUITests/ReviewMonitorLogProjectionTests.swift b/Tests/ReviewUITests/ReviewMonitorLogProjectionTests.swift
deleted file mode 100644
index e07bbd98..00000000
--- a/Tests/ReviewUITests/ReviewMonitorLogProjectionTests.swift
+++ /dev/null
@@ -1,1442 +0,0 @@
-import Foundation
-import Testing
-@_spi(Testing) @testable import CodexReview
-@_spi(PreviewSupport) @testable import ReviewUI
-
-@Suite("ReviewMonitor log projection")
-@MainActor
-struct ReviewMonitorLogProjectionTests {
- @Test func documentIncludesCommandOutputAndKeepsPlainTranscript() {
- let job = CodexReviewJob.makeForTesting(
- id: "job-command-output",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .succeeded,
- summary: "Done",
- logEntries: [
- .init(kind: .command, text: "$ git diff --stat"),
- .init(kind: .commandOutput, groupID: "cmd-1", text: "README.md | 1 +"),
- .init(kind: .agentMessage, text: "No correctness issues found."),
- ]
- )
- let document = document(for: job)
-
- #expect(document.text == """
- $ git diff --stat
-
- README.md | 1 +
-
- No correctness issues found.
- """)
- #expect(document.blocks.map(\.kind) == [.command, .commandOutput, .agentMessage])
- #expect(document.blocks[0].range == NSRange(
- location: 0,
- length: ("$ git diff --stat" as NSString).length
- ))
- #expect(document.blocks[1].range == NSRange(
- location: ("$ git diff --stat\n\n" as NSString).length,
- length: ("README.md | 1 +" as NSString).length
- ))
- #expect(document.blocks[2].range == NSRange(
- location: ("$ git diff --stat\n\nREADME.md | 1 +\n\n" as NSString).length,
- length: ("No correctness issues found." as NSString).length
- ))
- }
-
- @Test func logLineCounterMatchesCommandOutputLineCountingContract() {
- let cases: [(text: String, expected: Int)] = [
- ("", 0),
- ("a", 1),
- ("a\n", 1),
- ("\n", 1),
- ("a\nb", 2),
- ("a\nb\n", 2),
- ("a\n\nb", 3),
- ]
-
- for testCase in cases {
- #expect(ReviewMonitorLog.LineCounter.lineCount(testCase.text) == testCase.expected)
- let wrappedText = "prefix\(testCase.text)suffix"
- let string = wrappedText as NSString
- let range = NSRange(
- location: ("prefix" as NSString).length,
- length: (testCase.text as NSString).length
- )
- #expect(ReviewMonitorLog.LineCounter.lineCount(in: string, range: range) == testCase.expected)
- }
- }
-
- @Test func commandOutputDisplayPreservesReasoningAppendAnimationSpans() throws {
- var projection = ReviewMonitorLog.Projection()
- let initialEntries = [
- ReviewLogEntry(kind: .command, groupID: "cmd-1", text: "$ swift test"),
- ReviewLogEntry(kind: .commandOutput, groupID: "cmd-1", text: "Tests passed"),
- ReviewLogEntry(kind: .reasoning, groupID: "reasoning-1", text: "First reasoning"),
- ]
- _ = projection.render(entries: initialEntries)
-
- let appendedDocument = projection.append(
- entries: [
- .init(kind: .reasoning, groupID: "reasoning-1", text: " second reasoning"),
- ],
- sourceRange: initialEntries.count..<(initialEntries.count + 1)
- )
- let sourceDocument = try #require(appendedDocument)
- let displayDocument = ReviewMonitorCommandOutputDisplayDocument.make(from: sourceDocument)
- guard case .append(let append) = displayDocument.lastChange else {
- Issue.record("Expected display document append")
- return
- }
-
- #expect(append.text == " second reasoning")
- #expect(append.animationSpans == [
- .init(
- kind: .wordFade,
- range: NSRange(location: 0, length: (" second reasoning" as NSString).length)
- ),
- ])
- }
-
- @Test func progressAppendDoesNotProduceAnimationSpans() throws {
- var projection = ReviewMonitorLog.Projection()
- let initialEntries = [
- ReviewLogEntry(kind: .agentMessage, groupID: "msg-1", text: "Initial"),
- ]
- _ = projection.render(entries: initialEntries)
-
- let maybeAppendedDocument = projection.append(
- entries: [
- .init(kind: .progress, groupID: "progress-1", text: "stream.tick 001"),
- ],
- sourceRange: initialEntries.count..<(initialEntries.count + 1)
- )
- let appendedDocument = try #require(maybeAppendedDocument)
- guard case .append(let append) = appendedDocument.lastChange else {
- Issue.record("Expected progress update to append.")
- return
- }
-
- #expect(append.text.hasSuffix("stream.tick 001"))
- #expect(append.animationSpans.isEmpty)
- }
-
- @Test func rendererMapsCommandCompletionThenReasoningAppendWithoutReload() async throws {
- let startedAt = Date(timeIntervalSince1970: 200)
- let completedAt = Date(timeIntervalSince1970: 203)
- let initialEntries: [ReviewLogEntry] = [
- .init(
- kind: .command,
- groupID: "cmd-1",
- text: "$ git diff",
- metadata: .init(
- sourceType: "commandExecution",
- status: "inProgress",
- itemID: "cmd-1",
- command: "git diff",
- startedAt: startedAt,
- commandStatus: "inProgress"
- )
- ),
- ]
- let appendedEntries: [ReviewLogEntry] = [
- .init(
- kind: .command,
- groupID: "cmd-1",
- replacesGroup: true,
- text: "$ git diff",
- metadata: .init(
- sourceType: "commandExecution",
- status: "completed",
- itemID: "cmd-1",
- command: "git diff",
- exitCode: 0,
- startedAt: startedAt,
- completedAt: completedAt,
- durationMs: 3_000,
- commandStatus: "completed"
- )
- ),
- .init(
- kind: .rawReasoning,
- groupID: "reasoning-1",
- text: "I found the relevant update path."
- ),
- ]
- let renderer = ReviewMonitorLogRenderer()
- _ = await renderer.render(entries: initialEntries)
-
- let documents = try #require(await renderer.appendSteps(
- entries: appendedEntries,
- sourceRange: initialEntries.count..<(initialEntries.count + appendedEntries.count)
- ))
- let commandCompletionDocument = try #require(documents.first)
- let reasoningAppendDocument = try #require(documents.dropFirst().first)
- #expect(documents.dropFirst(2).isEmpty)
-
- guard case .replace(let replacement) = commandCompletionDocument.display.lastChange else {
- Issue.record("Expected command completion to map to a display replacement.")
- return
- }
- #expect(replacement.blockID == ReviewMonitorLog.BlockID("commandOutput:cmd-1"))
- #expect(ReviewMonitorCommandOutputDisplayDocument.userVisibleText(
- from: replacement.text
- ) == "Ran git diff for 3s")
-
- guard case .append(let append) = reasoningAppendDocument.display.lastChange else {
- Issue.record("Expected reasoning to remain a display append.")
- return
- }
- #expect(append.text == "\n\nI found the relevant update path.")
- #expect(append.animationSpans == [
- .init(
- kind: .wordFade,
- range: NSRange(
- location: ("\n\n" as NSString).length,
- length: ("I found the relevant update path." as NSString).length
- )
- ),
- ])
- }
-
- @Test func rendererAppendsNewCommandPanelAfterReasoningWithoutReplacingReasoning() async throws {
- let startedAt = Date(timeIntervalSince1970: 200)
- let initialEntries: [ReviewLogEntry] = [
- .init(
- kind: .rawReasoning,
- groupID: "reasoning-1",
- text: "Need to inspect files."
- ),
- ]
- let appendedEntries: [ReviewLogEntry] = [
- .init(
- kind: .command,
- groupID: "cmd-1",
- text: "$ git diff",
- metadata: .init(
- sourceType: "commandExecution",
- status: "inProgress",
- itemID: "cmd-1",
- command: "git diff",
- startedAt: startedAt,
- commandStatus: "inProgress"
- )
- ),
- ]
- let renderer = ReviewMonitorLogRenderer()
- _ = await renderer.render(entries: initialEntries)
-
- let documents = try #require(await renderer.appendSteps(
- entries: appendedEntries,
- sourceRange: initialEntries.count..<(initialEntries.count + appendedEntries.count)
- ))
- let commandAppendDocument = try #require(documents.first)
- #expect(documents.dropFirst().isEmpty)
- let visibleText = ReviewMonitorCommandOutputDisplayDocument.userVisibleText(
- from: commandAppendDocument.display.text
- )
-
- #expect(visibleText.contains("Need to inspect files."))
- #expect(visibleText.contains("Running git diff"))
- guard case .append(let append) = commandAppendDocument.display.lastChange else {
- Issue.record("Expected new command panel to map to a display append.")
- return
- }
- #expect(append.blockID == ReviewMonitorLog.BlockID("commandOutput:cmd-1"))
- #expect(append.text.hasPrefix("\n\n"))
- #expect(ReviewMonitorCommandOutputDisplayDocument.userVisibleText(
- from: append.text
- ).hasPrefix("\n\nRunning git diff"))
- #expect(append.animationSpans.isEmpty)
- }
-
- @Test func rendererKeepsBlankSeparatorBetweenCommandPanelAndFollowingReasoning() async throws {
- let startedAt = Date(timeIntervalSince1970: 200)
- let completedAt = Date(timeIntervalSince1970: 213)
- let initialEntries: [ReviewLogEntry] = [
- .init(
- kind: .command,
- groupID: "cmd-1",
- text: "$ sed -n '1,120p' Sources/File.swift",
- metadata: .init(
- sourceType: "commandExecution",
- status: "completed",
- itemID: "cmd-1",
- command: "sed -n '1,120p' Sources/File.swift",
- exitCode: 0,
- startedAt: startedAt,
- completedAt: completedAt,
- durationMs: 13_000,
- commandStatus: "completed"
- )
- ),
- ]
- let appendedEntries: [ReviewLogEntry] = [
- .init(
- kind: .rawReasoning,
- groupID: "reasoning-1",
- text: "Inspecting network components\n\nI need to inspect the details."
- ),
- ]
- let renderer = ReviewMonitorLogRenderer()
- _ = await renderer.render(entries: initialEntries)
-
- let documents = try #require(await renderer.appendSteps(
- entries: appendedEntries,
- sourceRange: initialEntries.count..<(initialEntries.count + appendedEntries.count)
- ))
- let reasoningAppendDocument = try #require(documents.first)
- let visibleText = ReviewMonitorCommandOutputDisplayDocument.userVisibleText(
- from: reasoningAppendDocument.display.text
- )
-
- #expect(visibleText.contains("Ran sed -n for 13s\n\nInspecting network components"))
- guard case .append(let append) = reasoningAppendDocument.display.lastChange else {
- Issue.record("Expected following reasoning to remain a display append.")
- return
- }
- #expect(append.text.hasPrefix("\n\nInspecting network components"))
- #expect(append.animationSpans == [
- .init(
- kind: .wordFade,
- range: NSRange(
- location: ("\n\n" as NSString).length,
- length: ("Inspecting network components\n\nI need to inspect the details." as NSString).length
- )
- ),
- ])
- }
-
- @Test func rendererReturnsSkippedCurrentDocumentBeforeLaterAppendSteps() async throws {
- let initialEntries: [ReviewLogEntry] = [
- .init(kind: .rawReasoning, groupID: "reasoning-1", text: "Need to inspect files.")
- ]
- let firstAppend = ReviewLogEntry(
- kind: .rawReasoning,
- groupID: "reasoning-2",
- text: "I found the first update."
- )
- let secondAppend = ReviewLogEntry(
- kind: .rawReasoning,
- groupID: "reasoning-3",
- text: "I found the second update."
- )
- let renderer = ReviewMonitorLogRenderer()
- _ = await renderer.render(entries: initialEntries)
-
- _ = try #require(await renderer.appendSteps(
- entries: [firstAppend],
- sourceRange: initialEntries.count..<(initialEntries.count + 1)
- ))
- let recoveredDocuments = try #require(await renderer.appendSteps(
- entries: [firstAppend, secondAppend],
- sourceRange: initialEntries.count..<(initialEntries.count + 2)
- ))
-
- #expect(recoveredDocuments.count == 2)
- #expect(recoveredDocuments[0].display.text.contains("I found the first update."))
- #expect(recoveredDocuments[0].display.text.contains("I found the second update.") == false)
- #expect(recoveredDocuments[1].display.text.contains("I found the first update."))
- #expect(recoveredDocuments[1].display.text.contains("I found the second update."))
- guard case .append(let append) = recoveredDocuments[1].display.lastChange else {
- Issue.record("Expected later append to keep its append change.")
- return
- }
- #expect(append.text == "\n\nI found the second update.")
- }
-
- @Test func contextCompactionMarkerUsesDedicatedProjectionStyle() {
- let metadata = ReviewLogEntry.Metadata(
- sourceType: "contextCompaction",
- status: "inProgress",
- itemID: "compact-1"
- )
- let job = CodexReviewJob.makeForTesting(
- id: "job-context-compaction",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(
- kind: .contextCompaction,
- groupID: "compact-1",
- replacesGroup: true,
- text: "Automatically compacting context",
- metadata: metadata
- ),
- ]
- )
- let document = document(for: job)
-
- #expect(document.text == "Automatically compacting context")
- #expect(document.blocks.map(\.kind) == [.contextCompaction])
- #expect(document.styleRuns.first?.style == .contextCompaction)
- #expect(document.decorations.first?.style == .contextCompaction(
- label: "Automatically compacting context",
- isCompleted: false
- ))
- }
-
- @Test func contextCompactionCompletionReplacesStartedMarker() {
- let job = CodexReviewJob.makeForTesting(
- id: "job-context-compaction-replacement",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(
- kind: .contextCompaction,
- groupID: "compact-1",
- replacesGroup: true,
- text: "Automatically compacting context",
- metadata: .init(
- sourceType: "contextCompaction",
- status: "inProgress",
- itemID: "compact-1"
- )
- ),
- ]
- )
- var projection = ReviewMonitorLog.Projection()
- let initialDocument = projection.render(entries: job.logEntries)
-
- job.appendLogEntry(.init(
- kind: .contextCompaction,
- groupID: "compact-1",
- replacesGroup: true,
- text: "Context automatically compacted",
- metadata: .init(
- sourceType: "contextCompaction",
- status: "completed",
- itemID: "compact-1"
- )
- ))
- let updatedDocument = projection.render(entries: job.logEntries)
-
- #expect(updatedDocument.text == "Context automatically compacted")
- #expect(updatedDocument.blocks.count == 1)
- #expect(updatedDocument.blocks.first?.kind == .contextCompaction)
- #expect(updatedDocument.revision == initialDocument.revision &+ 1)
- #expect(updatedDocument.lastChange == .replace(.init(
- kind: .contextCompaction,
- blockID: ReviewMonitorLog.BlockID("contextCompaction:compact-1"),
- range: NSRange(
- location: 0,
- length: ("Automatically compacting context" as NSString).length
- ),
- text: "Context automatically compacted"
- )))
- #expect(updatedDocument.decorations.first?.style == .contextCompaction(
- label: "Context automatically compacted",
- isCompleted: true
- ))
- }
-
- @Test func failedContextCompactionMarkerDoesNotUseCompletedDecoration() {
- let completedAt = Date(timeIntervalSince1970: 1_700_000_002)
- let job = CodexReviewJob.makeForTesting(
- id: "job-context-compaction-failed",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(
- kind: .contextCompaction,
- groupID: "compact-1",
- replacesGroup: true,
- text: "Context compaction failed",
- metadata: .init(
- sourceType: "contextCompaction",
- status: "failed",
- itemID: "compact-1",
- completedAt: completedAt
- )
- ),
- ]
- )
-
- let document = document(for: job)
-
- #expect(document.text == "Context compaction failed")
- #expect(document.decorations.first?.style == .contextCompaction(
- label: "Context compaction failed",
- isCompleted: false
- ))
- }
-
- @Test func commandOutputAppendUsesAppendChange() {
- let job = CodexReviewJob.makeForTesting(
- id: "job-command-output",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(kind: .command, text: "$ git diff --stat"),
- .init(kind: .commandOutput, groupID: "cmd-1", text: "README.md | 1 +"),
- ]
- )
- var projection = ReviewMonitorLog.Projection()
- let initialDocument = projection.render(entries: job.logEntries)
-
- job.appendLogEntry(.init(kind: .commandOutput, groupID: "cmd-1", text: "\nSources/App.swift | 2 +"))
- let updatedDocument = projection.render(entries: job.logEntries)
-
- #expect(updatedDocument.text == """
- $ git diff --stat
-
- README.md | 1 +
- Sources/App.swift | 2 +
- """)
- #expect(updatedDocument.revision == initialDocument.revision &+ 1)
- #expect(updatedDocument.lastChange == .append(.init(
- kind: .commandOutput,
- blockID: ReviewMonitorLog.BlockID("commandOutput:cmd-1"),
- range: NSRange(
- location: ("$ git diff --stat\n\nREADME.md | 1 +" as NSString).length,
- length: ("\nSources/App.swift | 2 +" as NSString).length
- ),
- text: "\nSources/App.swift | 2 +"
- )))
- #expect(job.logText.contains("Sources/App.swift | 2 +"))
- }
-
- @Test func commandDisplayUsesPanelBeforeOutputArrives() {
- let job = CodexReviewJob.makeForTesting(
- id: "job-command-started",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(kind: .command, groupID: "cmd-1", text: "$ swift test")
- ]
- )
- let sourceDocument = document(for: job)
- let displayDocument = ReviewMonitorCommandOutputDisplayDocument.make(
- from: sourceDocument,
- expandedBlockIDs: []
- )
- let displayText = ReviewMonitorCommandOutputDisplayDocument.userVisibleText(from: displayDocument.text)
-
- #expect(displayText == "Running swift test")
- #expect(displayDocument.text.contains("$ swift test") == false)
- #expect(displayDocument.decorations.isEmpty)
- #expect(displayDocument.commandOutputPanels.count == 1)
- #expect(displayDocument.commandOutputPanels.first?.blockID == ReviewMonitorLog.BlockID("commandOutput:cmd-1"))
- #expect(displayDocument.commandOutputPanels.first?.commandText == "swift test")
- #expect(displayDocument.commandOutputPanels.first?.isActive == true)
- }
-
- @Test func commandDisplayVisibleTextPreservesNewlineBeforeToggleAttachment() {
- let attachment = ReviewMonitorCommandOutputDisplayDocument.toggleAttachmentCharacter
- let displayText = "Agent line\n\(attachment)Ran swift test\n\(attachment)\nNext line"
-
- #expect(
- ReviewMonitorCommandOutputDisplayDocument.userVisibleText(from: displayText) ==
- "Agent line\nRan swift test\nNext line"
- )
- }
-
- @Test func duplicateStartedCommandsKeepUniquePanelIDs() {
- let job = CodexReviewJob.makeForTesting(
- id: "job-duplicate-command-started",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(kind: .command, groupID: "cmd-1", text: "$ swift test"),
- .init(kind: .command, groupID: "cmd-2", text: "$ swift test --filter ReviewUI"),
- ]
- )
- let sourceDocument = document(for: job)
- let displayDocument = ReviewMonitorCommandOutputDisplayDocument.make(
- from: sourceDocument,
- expandedBlockIDs: []
- )
- let blockIDs = displayDocument.commandOutputPanels.map(\.blockID)
-
- #expect(displayDocument.commandOutputPanels.count == 2)
- #expect(Set(blockIDs).count == blockIDs.count)
- #expect(blockIDs.first == ReviewMonitorLog.BlockID("commandOutput:cmd-1"))
- }
-
- @Test func activeCommandLifecycleDisplaysRunningTitle() {
- let startedAt = Date(timeIntervalSince1970: 100)
- let metadata = ReviewLogEntry.Metadata(
- sourceType: "commandExecution",
- status: "inProgress",
- itemID: "cmd-1",
- command: "swift test",
- startedAt: startedAt,
- commandStatus: "inProgress"
- )
- let job = CodexReviewJob.makeForTesting(
- id: "job-command-running",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(kind: .command, groupID: "cmd-1", text: "$ swift test", metadata: metadata),
- ]
- )
-
- let displayDocument = ReviewMonitorCommandOutputDisplayDocument.make(
- from: document(for: job),
- expandedBlockIDs: [],
- currentDate: Date(timeIntervalSince1970: 104)
- )
- let displayText = ReviewMonitorCommandOutputDisplayDocument.userVisibleText(from: displayDocument.text)
-
- #expect(displayText == "Running swift test")
- #expect(displayDocument.commandOutputPanels.first?.isActive == true)
- #expect(displayDocument.commandOutputPanels.first?.startedAt == startedAt)
- }
-
- @Test func completedCommandLifecycleDisplaysFixedDurationTitle() {
- let startedAt = Date(timeIntervalSince1970: 100)
- let completedMetadata = ReviewLogEntry.Metadata(
- sourceType: "commandExecution",
- status: "completed",
- itemID: "cmd-1",
- command: "swift test",
- startedAt: startedAt,
- completedAt: Date(timeIntervalSince1970: 103.4),
- durationMs: 3_000,
- commandStatus: "completed"
- )
- let job = CodexReviewJob.makeForTesting(
- id: "job-command-completed",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(kind: .command, groupID: "cmd-1", text: "$ swift test"),
- .init(kind: .commandOutput, groupID: "cmd-1", text: "ok", metadata: completedMetadata),
- ]
- )
- let sourceDocument = document(for: job)
- let firstDisplayDocument = ReviewMonitorCommandOutputDisplayDocument.make(
- from: sourceDocument,
- expandedBlockIDs: [],
- currentDate: Date(timeIntervalSince1970: 120)
- )
- let laterDisplayDocument = ReviewMonitorCommandOutputDisplayDocument.make(
- from: sourceDocument,
- expandedBlockIDs: [],
- currentDate: Date(timeIntervalSince1970: 180)
- )
-
- #expect(firstDisplayDocument.text == laterDisplayDocument.text)
- #expect(ReviewMonitorCommandOutputDisplayDocument.userVisibleText(
- from: firstDisplayDocument.text
- ) == "Ran swift test for 3s")
- #expect(firstDisplayDocument.commandOutputPanels.first?.isActive == false)
- }
-
- @Test func statusOnlyCompletedCommandWithoutOutputDisplaysRanTitle() {
- let job = CodexReviewJob.makeForTesting(
- id: "job-command-completed-status-only",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(
- kind: .command,
- groupID: "cmd-1",
- text: "$ swift test",
- metadata: .init(sourceType: "commandExecution", status: "completed")
- ),
- ]
- )
-
- let displayDocument = ReviewMonitorCommandOutputDisplayDocument.make(
- from: document(for: job),
- expandedBlockIDs: []
- )
- let displayText = ReviewMonitorCommandOutputDisplayDocument.userVisibleText(from: displayDocument.text)
-
- #expect(displayText == "Ran swift test")
- #expect(displayDocument.commandOutputPanels.first?.isActive == false)
- }
-
- @Test func canceledCommandStatusIsInactive() {
- let startedAt = Date(timeIntervalSince1970: 100)
- let job = CodexReviewJob.makeForTesting(
- id: "job-command-canceled",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(
- kind: .command,
- groupID: "cmd-1",
- text: "$ swift test",
- metadata: .init(
- sourceType: "commandExecution",
- status: "canceled",
- command: "swift test",
- startedAt: startedAt
- )
- ),
- ]
- )
-
- let displayDocument = ReviewMonitorCommandOutputDisplayDocument.make(
- from: document(for: job),
- expandedBlockIDs: []
- )
- let displayText = ReviewMonitorCommandOutputDisplayDocument.userVisibleText(from: displayDocument.text)
- let attachmentCount = displayDocument.text.filter {
- String($0) == ReviewMonitorCommandOutputDisplayDocument.toggleAttachmentCharacter
- }.count
-
- #expect(displayText == "Ran swift test")
- #expect(displayDocument.commandOutputPanels.first?.isActive == false)
- #expect(attachmentCount == 1)
- }
-
- @Test func commandActionsDriveReadSearchAndListTitles() {
- let readJob = CodexReviewJob.makeForTesting(
- id: "job-command-read",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(
- kind: .command,
- groupID: "cmd-1",
- text: "$ cat ThreadItem.ts",
- metadata: .init(
- sourceType: "commandExecution",
- status: "inProgress",
- itemID: "cmd-1",
- command: "cat ThreadItem.ts",
- commandActions: [
- .init(kind: .read, command: "cat ThreadItem.ts", name: "ThreadItem.ts", path: "ThreadItem.ts")
- ],
- commandStatus: "inProgress"
- )
- ),
- ]
- )
- let searchJob = CodexReviewJob.makeForTesting(
- id: "job-command-search",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(kind: .command, groupID: "cmd-2", text: "$ rg files"),
- .init(
- kind: .commandOutput,
- groupID: "cmd-2",
- text: "ThreadItem.ts",
- metadata: .init(
- sourceType: "commandExecution",
- status: "completed",
- itemID: "cmd-2",
- command: "rg files",
- durationMs: 2_000,
- commandActions: [
- .init(kind: .search, command: "rg files", path: "/tmp/workspace", query: "files")
- ],
- commandStatus: "completed"
- )
- ),
- ]
- )
-
- let readText = ReviewMonitorCommandOutputDisplayDocument.make(
- from: document(for: readJob),
- expandedBlockIDs: []
- ).text
- let visibleReadText = ReviewMonitorCommandOutputDisplayDocument.userVisibleText(from: readText)
- let searchText = ReviewMonitorCommandOutputDisplayDocument.make(
- from: document(for: searchJob),
- expandedBlockIDs: []
- ).text
- let visibleSearchText = ReviewMonitorCommandOutputDisplayDocument.userVisibleText(from: searchText)
-
- #expect(visibleReadText == "Reading ThreadItem.ts")
- #expect(visibleSearchText == "Searched files in workspace for 2s")
- }
-
- @Test func commandTimerAttachmentViewCountsUpFromStartDate() {
- let attachment = ReviewMonitorCommandOutputTimerAttachment(
- blockID: ReviewMonitorLog.BlockID("commandOutput:cmd-1"),
- startedAt: Date(timeIntervalSince1970: 100),
- font: .systemFont(ofSize: 13)
- )
- let view = ReviewMonitorCommandOutputTimerAttachmentView(attachment: attachment)
-
- view.updateText(referenceDate: Date(timeIntervalSince1970: 103), animated: false)
- #expect(view.displayedTextForTesting == " for 3s")
- #expect(view.accessibilityRole() == .staticText)
- #expect(view.accessibilityLabel() == "for 3s")
- #expect(view.accessibilityValue() as? String == "for 3s")
- view.updateText(referenceDate: Date(timeIntervalSince1970: 164), animated: false)
- #expect(view.displayedTextForTesting == " for 1m 4s")
- #expect(view.accessibilityLabel() == "for 1m 4s")
- #expect(view.accessibilityValue() as? String == "for 1m 4s")
- }
-
- @Test func commandTimerAttachmentViewAnimatesChangedDigits() {
- let documentView = ReviewMonitorLogDocumentView()
- documentView.reduceMotionOverrideForTesting = false
- let attachment = ReviewMonitorCommandOutputTimerAttachment(
- blockID: ReviewMonitorLog.BlockID("commandOutput:cmd-1"),
- startedAt: Date(timeIntervalSince1970: 100),
- font: .systemFont(ofSize: 13)
- )
- let view = ReviewMonitorCommandOutputTimerAttachmentView(attachment: attachment)
- documentView.addSubview(view)
- defer { view.removeFromSuperview() }
-
- view.updateText(referenceDate: Date(timeIntervalSince1970: 103), animated: false)
- view.updateText(referenceDate: Date(timeIntervalSince1970: 104))
-
- #expect(view.displayedTextForTesting == " for 4s")
- #expect(view.activeNumericTransitionCountForTesting > 0)
- view.completeNumericTransitionsForTesting()
- #expect(view.activeNumericTransitionCountForTesting == 0)
- }
-
- @Test func commandTimerAttachmentViewResyncPreservesActiveTransition() {
- let documentView = ReviewMonitorLogDocumentView()
- documentView.reduceMotionOverrideForTesting = false
- let attachment = ReviewMonitorCommandOutputTimerAttachment(
- blockID: ReviewMonitorLog.BlockID("commandOutput:cmd-1"),
- startedAt: Date(timeIntervalSince1970: 100),
- font: .systemFont(ofSize: 13)
- )
- let view = ReviewMonitorCommandOutputTimerAttachmentView(attachment: attachment)
- documentView.addSubview(view)
- defer { view.removeFromSuperview() }
-
- view.updateText(referenceDate: Date(timeIntervalSince1970: 103), animated: false)
- view.updateText(referenceDate: Date(timeIntervalSince1970: 104))
- #expect(view.activeNumericTransitionCountForTesting > 0)
-
- view.configure(attachment: attachment)
-
- #expect(view.activeNumericTransitionCountForTesting > 0)
- }
-
- @Test func commandTimerAttachmentViewStopsActiveTransitionWhenReconfiguredDisabled() {
- let documentView = ReviewMonitorLogDocumentView()
- documentView.reduceMotionOverrideForTesting = false
- let blockID = ReviewMonitorLog.BlockID("commandOutput:cmd-1")
- let startedAt = Date(timeIntervalSince1970: 100)
- let attachment = ReviewMonitorCommandOutputTimerAttachment(
- blockID: blockID,
- startedAt: startedAt,
- font: .systemFont(ofSize: 13)
- )
- let view = ReviewMonitorCommandOutputTimerAttachmentView(attachment: attachment)
- documentView.addSubview(view)
- defer { view.removeFromSuperview() }
-
- view.updateText(referenceDate: Date(timeIntervalSince1970: 103), animated: false)
- view.updateText(referenceDate: Date(timeIntervalSince1970: 104))
- #expect(view.activeNumericTransitionCountForTesting > 0)
- let disabledAttachment = ReviewMonitorCommandOutputTimerAttachment(
- blockID: blockID,
- startedAt: startedAt,
- font: .systemFont(ofSize: 13),
- animatesNumericTransition: false
- )
-
- view.configure(attachment: disabledAttachment)
-
- #expect(view.activeNumericTransitionCountForTesting == 0)
- }
-
- @Test func commandTimerAttachmentViewKeepsWidthWhenDigitsGrow() {
- let attachment = ReviewMonitorCommandOutputTimerAttachment(
- blockID: ReviewMonitorLog.BlockID("commandOutput:cmd-1"),
- startedAt: Date(timeIntervalSince1970: 100),
- font: .systemFont(ofSize: 13)
- )
- let view = ReviewMonitorCommandOutputTimerAttachmentView(attachment: attachment)
- let initialWidth = view.intrinsicContentSize.width
-
- view.updateText(referenceDate: Date(timeIntervalSince1970: 109), animated: false)
- view.updateText(referenceDate: Date(timeIntervalSince1970: 110))
-
- #expect(view.displayedTextForTesting == " for 10s")
- #expect(view.intrinsicContentSize.width == initialWidth)
- }
-
- @Test func commandTimerAttachmentViewLongTextFitsAttachmentWidth() {
- let attachment = ReviewMonitorCommandOutputTimerAttachment(
- blockID: ReviewMonitorLog.BlockID("commandOutput:cmd-1"),
- startedAt: Date(timeIntervalSince1970: 100),
- font: .systemFont(ofSize: 13)
- )
- let view = ReviewMonitorCommandOutputTimerAttachmentView(attachment: attachment)
-
- view.updateText(referenceDate: Date(timeIntervalSince1970: 3_699), animated: false)
-
- #expect(view.displayedTextForTesting == " for 59m 59s")
- #expect(view.renderedTextWidthForTesting <= view.intrinsicContentSize.width)
- }
-
- @Test func commandTimerAttachmentViewDoesNotAnimateWhenDisabled() {
- let attachment = ReviewMonitorCommandOutputTimerAttachment(
- blockID: ReviewMonitorLog.BlockID("commandOutput:cmd-1"),
- startedAt: Date(timeIntervalSince1970: 100),
- font: .systemFont(ofSize: 13),
- animatesNumericTransition: false
- )
- let view = ReviewMonitorCommandOutputTimerAttachmentView(attachment: attachment)
-
- view.updateText(referenceDate: Date(timeIntervalSince1970: 103), animated: false)
- view.updateText(referenceDate: Date(timeIntervalSince1970: 104))
-
- #expect(view.displayedTextForTesting == " for 4s")
- #expect(view.activeNumericTransitionCountForTesting == 0)
- }
-
- @Test func commandTimerAttachmentViewUsesCurrentDocumentReduceMotionState() {
- let documentView = ReviewMonitorLogDocumentView()
- documentView.reduceMotionOverrideForTesting = false
- let attachment = ReviewMonitorCommandOutputTimerAttachment(
- blockID: ReviewMonitorLog.BlockID("commandOutput:cmd-1"),
- startedAt: Date(timeIntervalSince1970: 100),
- font: .systemFont(ofSize: 13)
- )
- let view = ReviewMonitorCommandOutputTimerAttachmentView(attachment: attachment)
- documentView.addSubview(view)
- defer { view.removeFromSuperview() }
-
- view.updateText(referenceDate: Date(timeIntervalSince1970: 103), animated: false)
- view.updateText(referenceDate: Date(timeIntervalSince1970: 104))
- #expect(view.activeNumericTransitionCountForTesting > 0)
- view.completeNumericTransitionsForTesting()
-
- documentView.reduceMotionOverrideForTesting = true
- view.updateText(referenceDate: Date(timeIntervalSince1970: 105))
-
- #expect(view.displayedTextForTesting == " for 5s")
- #expect(view.activeNumericTransitionCountForTesting == 0)
-
- documentView.reduceMotionOverrideForTesting = false
- view.updateText(referenceDate: Date(timeIntervalSince1970: 106))
-
- #expect(view.displayedTextForTesting == " for 6s")
- #expect(view.activeNumericTransitionCountForTesting > 0)
- }
-
- @Test func commandOutputDisplayKeepsCommandPanelBeforeInterleavedBlocks() {
- let job = CodexReviewJob.makeForTesting(
- id: "job-command-output-interleaved",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(kind: .command, groupID: "cmd-1", text: "$ swift test"),
- .init(kind: .toolCall, text: "MCP codex_review.review_read started."),
- .init(
- kind: .commandOutput,
- groupID: "cmd-1",
- text: "Tests passed",
- metadata: .init(sourceType: "command", title: "Ran command for 3s")
- ),
- ]
- )
- let sourceDocument = document(for: job)
- let displayDocument = ReviewMonitorCommandOutputDisplayDocument.make(
- from: sourceDocument,
- expandedBlockIDs: []
- )
-
- let displayText = ReviewMonitorCommandOutputDisplayDocument.userVisibleText(from: displayDocument.text)
- #expect(displayText.hasPrefix("Ran command for 3s\n\nMCP codex_review.review_read started."))
- #expect(displayDocument.text.contains("$ swift test") == false)
- }
-
- @Test func commandOutputDisplayLetsExitCodeOverrideCompletedStatus() {
- let job = CodexReviewJob.makeForTesting(
- id: "job-command-output-exit-code",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(kind: .command, groupID: "cmd-1", text: "$ swift test"),
- .init(
- kind: .commandOutput,
- groupID: "cmd-1",
- text: "Tests failed",
- metadata: .init(
- sourceType: "command",
- title: "Ran command for 10s",
- status: "completed",
- exitCode: 1
- )
- ),
- ]
- )
- let sourceDocument = document(for: job)
- let displayDocument = ReviewMonitorCommandOutputDisplayDocument.make(
- from: sourceDocument,
- expandedBlockIDs: [ReviewMonitorLog.BlockID("commandOutput:cmd-1")]
- )
-
- #expect(displayDocument.commandOutputPanels.first?.exitText == "exit 1")
- }
-
- @Test func commandOutputPanelResultUsesMergedCompletionMetadata() {
- let job = CodexReviewJob.makeForTesting(
- id: "job-command-output-merged-result",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(
- kind: .command,
- groupID: "cmd-1",
- replacesGroup: true,
- text: "$ swift test",
- metadata: .init(
- sourceType: "commandExecution",
- status: "inProgress",
- itemID: "cmd-1",
- command: "swift test",
- commandStatus: "inProgress"
- )
- ),
- .init(
- kind: .commandOutput,
- groupID: "cmd-1",
- text: "Tests passed",
- metadata: .init(
- sourceType: "commandExecution",
- title: "Command output",
- itemID: "cmd-1"
- )
- ),
- .init(
- kind: .command,
- groupID: "cmd-1",
- replacesGroup: true,
- text: "$ swift test",
- metadata: .init(
- sourceType: "commandExecution",
- status: "succeeded",
- itemID: "cmd-1",
- command: "swift test",
- exitCode: 0,
- commandStatus: "succeeded"
- )
- ),
- ]
- )
-
- let displayDocument = ReviewMonitorCommandOutputDisplayDocument.make(
- from: document(for: job),
- expandedBlockIDs: [ReviewMonitorLog.BlockID("commandOutput:cmd-1")]
- )
-
- #expect(displayDocument.commandOutputPanels.first?.outputText == "Tests passed")
- #expect(displayDocument.commandOutputPanels.first?.exitText == "Success")
- }
-
- @Test func collapsedCommandOutputDisplayKeepsOutputAsSourceRangeOnly() {
- let job = CodexReviewJob.makeForTesting(
- id: "job-command-output-collapsed-source-range",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(kind: .command, groupID: "cmd-1", text: "$ swift test"),
- .init(
- kind: .commandOutput,
- groupID: "cmd-1",
- text: "Tests passed\nCoverage complete",
- metadata: .init(sourceType: "commandExecution", title: "Command output")
- ),
- ]
- )
- let sourceDocument = document(for: job)
- let blockID = ReviewMonitorLog.BlockID("commandOutput:cmd-1")
-
- let collapsedDocument = ReviewMonitorCommandOutputDisplayDocument.make(
- from: sourceDocument,
- expandedBlockIDs: []
- )
- let expandedDocument = ReviewMonitorCommandOutputDisplayDocument.make(
- from: sourceDocument,
- expandedBlockIDs: [blockID]
- )
-
- let collapsedPanel = collapsedDocument.commandOutputPanels.first
- #expect(collapsedPanel?.outputText == "")
- #expect(collapsedPanel?.lineCount == 0)
- #expect(collapsedPanel?.outputSourceRange != nil)
- #expect(expandedDocument.commandOutputPanels.first?.outputText == "Tests passed\nCoverage complete")
- #expect(expandedDocument.commandOutputPanels.first?.lineCount == 2)
- }
-
- @Test func collapsedCommandOutputFinderSupplementSignatureIgnoresHiddenOutputContent() {
- let firstJob = CodexReviewJob.makeForTesting(
- id: "job-command-output-signature-1",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(kind: .command, groupID: "cmd-1", text: "$ swift test"),
- .init(
- kind: .commandOutput,
- groupID: "cmd-1",
- text: "abc",
- metadata: .init(sourceType: "commandExecution", title: "Command output")
- ),
- ]
- )
- let secondJob = CodexReviewJob.makeForTesting(
- id: "job-command-output-signature-2",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(kind: .command, groupID: "cmd-1", text: "$ swift test"),
- .init(
- kind: .commandOutput,
- groupID: "cmd-1",
- text: "xyz",
- metadata: .init(sourceType: "commandExecution", title: "Command output")
- ),
- ]
- )
-
- let firstDocument = ReviewMonitorCommandOutputDisplayDocument.make(
- from: document(for: firstJob),
- expandedBlockIDs: []
- )
- let secondDocument = ReviewMonitorCommandOutputDisplayDocument.make(
- from: document(for: secondJob),
- expandedBlockIDs: []
- )
-
- #expect(firstDocument.text == secondDocument.text)
- #expect(firstDocument.commandOutputPanels.first?.outputSourceRange?.length == 3)
- #expect(secondDocument.commandOutputPanels.first?.outputSourceRange?.length == 3)
- #expect(firstDocument.finderSupplementSignature == secondDocument.finderSupplementSignature)
- }
-
- @Test func metadataIsPreservedOnBlocks() {
- let metadata = ReviewLogEntry.Metadata(
- sourceType: "commandExecution",
- title: "Command",
- status: "started",
- command: "swift test",
- cwd: "/tmp/workspace",
- exitCode: 0
- )
- let job = CodexReviewJob.makeForTesting(
- id: "job-metadata",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(kind: .command, groupID: "cmd-1", text: "$ swift test", metadata: metadata),
- ]
- )
- let document = document(for: job)
-
- #expect(document.blocks.first?.metadata == metadata)
- #expect(document.decorations.first?.style == .command(tone: .success))
- }
-
- @Test func groupedReplacementCanClearMetadata() {
- let metadata = ReviewLogEntry.Metadata(
- sourceType: "commandExecution",
- title: "Command",
- status: "started",
- command: "swift test"
- )
- let job = CodexReviewJob.makeForTesting(
- id: "job-metadata-clear",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(kind: .commandOutput, groupID: "cmd-1", text: "running", metadata: metadata),
- .init(kind: .commandOutput, groupID: "cmd-1", replacesGroup: true, text: "finished"),
- ]
- )
- let document = document(for: job)
-
- #expect(document.text == "finished")
- #expect(document.blocks.first?.metadata == nil)
- #expect(document.decorations.first?.style == .terminal(tone: .neutral))
- }
-
- @Test func documentRendersMarkdownWithStandardParserAndKeepsSourceTranscript() {
- let text = """
- # Heading
- - `inline` item with **strong**, *emphasis*, [link](https://example.com), and ~~old~~
- > quote
- ```swift
- let value = 1
- ```
- """
- let job = CodexReviewJob.makeForTesting(
- id: "job-markdown-lite",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .succeeded,
- summary: "Done",
- logEntries: [
- .init(kind: .agentMessage, text: text),
- ]
- )
- let document = document(for: job)
-
- #expect(document.text == """
- Heading
-
- - inline item with strong, emphasis, link, and old
-
- quote
-
- let value = 1
-
- """)
- #expect(document.sourceText == text)
- #expect(document.styleRuns.contains { $0.style == .heading(level: 1) })
- #expect(document.styleRuns.contains { $0.style == .bullet })
- #expect(document.styleRuns.contains { $0.style == .inlineCode })
- #expect(document.styleRuns.contains { $0.style == .strong })
- #expect(document.styleRuns.contains { $0.style == .emphasis })
- #expect(document.styleRuns.contains { $0.style == .link })
- #expect(document.styleRuns.contains { $0.style == .strikethrough })
- #expect(document.styleRuns.contains { $0.style == .blockquote })
- #expect(document.styleRuns.contains { $0.style == .codeFence })
- #expect(document.decorations.contains { $0.style == .codeBlock })
- }
-
- @Test func plainMultilineAgentTextKeepsLineBreaks() {
- let text = "line 1\nline 2\nline 3"
- let job = CodexReviewJob.makeForTesting(
- id: "job-plain-lines",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .succeeded,
- summary: "Done",
- logEntries: [
- .init(kind: .agentMessage, text: text),
- ]
- )
- let document = document(for: job)
-
- #expect(document.text == text)
- #expect(document.sourceText == text)
- }
-
- @Test func planStatusStylesAreProjected() {
- let job = CodexReviewJob.makeForTesting(
- id: "job-plan-style",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(kind: .todoList, groupID: "plan-1", text: "[completed] Inspect\n[in_progress] Render\n[pending] Test"),
- ]
- )
- let document = document(for: job)
-
- #expect(document.text == """
- ✓ Inspect
- • Render
- â–¡ Test
- """)
- #expect(document.sourceText == "[completed] Inspect\n[in_progress] Render\n[pending] Test")
- #expect(document.styleRuns.contains { $0.style == .plan(status: .completed) })
- #expect(document.styleRuns.contains { $0.style == .plan(status: .inProgress) })
- #expect(document.styleRuns.contains { $0.style == .plan(status: .pending) })
- }
-
- @Test func rawDiffEventRemainsMonospacedEventWithoutDiffParsing() {
- let diff = """
- diff --git a/A.swift b/A.swift
- +let value = 1
- """
- let job = CodexReviewJob.makeForTesting(
- id: "job-raw-diff",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(kind: .event, groupID: "turn-1", replacesGroup: true, text: diff),
- ]
- )
- let document = document(for: job)
-
- #expect(document.text == diff)
- #expect(document.styleRuns == [
- .init(range: NSRange(location: 0, length: (diff as NSString).length), style: .event)
- ])
- #expect(document.decorations.map(\.style) == [.event])
- }
-
- @Test func tailAgentMessageDeltaUsesAppendChangeWhenRenderedTextKeepsPrefix() {
- let job = CodexReviewJob.makeForTesting(
- id: "job-agent-delta",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(kind: .agentMessage, groupID: "msg-1", text: "Initial"),
- ]
- )
- var projection = ReviewMonitorLog.Projection()
- _ = projection.render(entries: job.logEntries)
-
- job.appendLogEntry(.init(kind: .agentMessage, groupID: "msg-1", text: " log"))
- let document = projection.render(entries: job.logEntries)
-
- #expect(document.text == "Initial log")
- #expect(document.blocks == [
- .init(
- id: ReviewMonitorLog.BlockID("agentMessage:msg-1"),
- kind: .agentMessage,
- groupID: "msg-1",
- range: NSRange(location: 0, length: ("Initial log" as NSString).length)
- )
- ])
- #expect(document.lastChange == .append(.init(
- kind: .agentMessage,
- blockID: ReviewMonitorLog.BlockID("agentMessage:msg-1"),
- range: NSRange(
- location: ("Initial" as NSString).length,
- length: (" log" as NSString).length
- ),
- text: " log"
- )))
- }
-
- @Test func tailAgentMessageDeltaRerendersMarkdownBlockWhenMarkupChangesPrefix() {
- let job = CodexReviewJob.makeForTesting(
- id: "job-agent-markdown-delta",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(kind: .agentMessage, groupID: "msg-1", text: "**bo"),
- ]
- )
- var projection = ReviewMonitorLog.Projection()
- _ = projection.render(entries: job.logEntries)
-
- job.appendLogEntry(.init(kind: .agentMessage, groupID: "msg-1", text: "ld**"))
- let document = projection.render(entries: job.logEntries)
-
- #expect(document.text == "bold")
- #expect(document.sourceText == "**bold**")
- #expect(document.lastChange == .replace(.init(
- kind: .agentMessage,
- blockID: ReviewMonitorLog.BlockID("agentMessage:msg-1"),
- range: NSRange(location: 0, length: ("**bo" as NSString).length),
- text: "bold"
- )))
- #expect(document.styleRuns.contains { $0.style == .strong })
- }
-
- @Test func incrementalAppendReplacesTailMarkdownBlockWithoutFullReload() {
- let firstEntry = ReviewLogEntry(kind: .agentMessage, groupID: "msg-1", text: "**bo")
- let appendedEntry = ReviewLogEntry(kind: .agentMessage, groupID: "msg-1", text: "ld**")
- var projection = ReviewMonitorLog.Projection()
- _ = projection.render(entries: [firstEntry])
-
- let incrementalDocument = projection.append(entries: [appendedEntry], sourceRange: 1..<2)
- #expect(incrementalDocument?.text == "bold")
- #expect(incrementalDocument?.sourceText == "**bold**")
- #expect(incrementalDocument?.lastChange == .replace(.init(
- kind: .agentMessage,
- blockID: ReviewMonitorLog.BlockID("agentMessage:msg-1"),
- range: NSRange(location: 0, length: ("**bo" as NSString).length),
- text: "bold"
- )))
-
- let document = projection.render(entries: [firstEntry, appendedEntry])
- #expect(document.text == "bold")
- #expect(document.sourceText == "**bold**")
- #expect(document.lastChange == .replace(.init(
- kind: .agentMessage,
- blockID: ReviewMonitorLog.BlockID("agentMessage:msg-1"),
- range: NSRange(location: 0, length: ("**bo" as NSString).length),
- text: "bold"
- )))
- }
-
- @Test func replacingGroupedPlanUsesReplacementChange() {
- let job = CodexReviewJob.makeForTesting(
- id: "job-plan-reload",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(kind: .plan, groupID: "plan-1", text: "- original"),
- ]
- )
- var projection = ReviewMonitorLog.Projection()
- _ = projection.render(entries: job.logEntries)
-
- job.appendLogEntry(.init(kind: .plan, groupID: "plan-1", replacesGroup: true, text: "- updated"))
- let document = projection.render(entries: job.logEntries)
-
- #expect(document.text == "- updated")
- #expect(document.lastChange == .replace(.init(
- kind: .plan,
- blockID: ReviewMonitorLog.BlockID("plan:plan-1"),
- range: NSRange(location: 0, length: ("- original" as NSString).length),
- text: "- updated"
- )))
- }
-
- @Test func cappedAgentMessageKeepsNewestText() {
- let text = "STALE BEGINNING\n" + String(repeating: "a", count: 270 * 1024) + "\nFINAL REVIEW TEXT"
- let job = CodexReviewJob.makeForTesting(
- id: "job-large-agent-message",
- cwd: "/tmp/workspace",
- targetSummary: "Uncommitted changes",
- status: .running,
- summary: "Running",
- logEntries: [
- .init(kind: .agentMessage, groupID: "msg-1", text: text),
- ]
- )
- let document = document(for: job)
-
- #expect(document.text.contains("FINAL REVIEW TEXT"))
- #expect(document.text.contains("STALE BEGINNING") == false)
- }
-
- private func document(for job: CodexReviewJob) -> ReviewMonitorLog.Document {
- var projection = ReviewMonitorLog.Projection()
- return projection.render(entries: job.logEntries)
- }
-}
diff --git a/Tests/ReviewUITests/ReviewMonitorLogTesting.swift b/Tests/ReviewUITests/ReviewMonitorLogTesting.swift
deleted file mode 100644
index 71834d07..00000000
--- a/Tests/ReviewUITests/ReviewMonitorLogTesting.swift
+++ /dev/null
@@ -1,8 +0,0 @@
-import CodexReview
-@_spi(PreviewSupport) @testable import ReviewUI
-
-@MainActor
-func reviewMonitorLogText(for job: CodexReviewJob) -> String {
- var projection = ReviewMonitorLog.Projection()
- return projection.render(entries: job.logEntries).text
-}
diff --git a/Tests/ReviewUITests/ReviewUISettingsTests.swift b/Tests/ReviewUITests/ReviewUISettingsTests.swift
index 5e9bf2ad..fd4aa5f5 100644
--- a/Tests/ReviewUITests/ReviewUISettingsTests.swift
+++ b/Tests/ReviewUITests/ReviewUISettingsTests.swift
@@ -1,8 +1,8 @@
import AppKit
import Foundation
import Testing
-@_spi(Testing) @testable import CodexReview
-@_spi(PreviewSupport) @testable import ReviewUI
+@_spi(Testing) @testable import CodexReviewKit
+@testable import ReviewUI
import CodexReviewTesting
@Suite(.serialized)
@@ -20,7 +20,6 @@ struct ReviewUISettingsTests {
store.loadForTesting(
serverState: .running,
authState: .signedIn(accountID: "review@example.com"),
- workspaces: [],
settingsSnapshot: settingsSnapshot
)
@@ -37,7 +36,6 @@ struct ReviewUISettingsTests {
store.loadForTesting(
serverState: .stopped,
authState: .signedIn(accountID: "review@example.com"),
- workspaces: [],
settingsSnapshot: settingsSnapshot
)
diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift
index ff6801d1..8d8854bc 100644
--- a/Tests/ReviewUITests/ReviewUIShellTests.swift
+++ b/Tests/ReviewUITests/ReviewUIShellTests.swift
@@ -1,10 +1,13 @@
import AppKit
+import CodexAppServerKitTesting
+import CodexKit
import Foundation
import ObservationBridge
import SwiftUI
import Testing
-@_spi(Testing) @testable import CodexReview
-@_spi(PreviewSupport) @testable import ReviewUI
+@_spi(Testing) @testable import CodexReviewKit
+@testable import ReviewUI
+import ReviewUIPreviewSupport
import CodexReviewTesting
@MainActor
@@ -20,9 +23,622 @@ extension ReviewUITests {
#expect(rootViewController.isSplitViewEmbeddedForTesting)
}
+ @Test func previewPreparationLoadsSelectedChatStreamBeforeWindowAttachment() async throws {
+ let previewContent = ReviewMonitorPreviewContent.makeContentSource()
+ let store = previewContent.store
+ let selectedChatID = try #require(previewSelectedChatID(in: store))
+ let viewController = makeReviewMonitorPreviewContentViewControllerForPreview(
+ previewContent: previewContent
+ )
+
+ #expect(viewController.isViewLoaded == false)
+
+ viewController.prepareForSwiftUIPreviewRendering()
+
+ #expect(viewController.isViewLoaded)
+ #expect(viewController.isSplitViewEmbeddedForTesting)
+ #expect(viewController.splitViewControllerForTesting.isTransportViewLoadedForTesting)
+
+ let transport = viewController.splitViewControllerForTesting.transportViewControllerForTesting
+ let snapshot = try await awaitTransportRender(
+ transport,
+ expectedSelection: .chat(selectedChatID.rawValue)
+ ) { snapshot in
+ snapshot.log.isEmpty == false && snapshot.isShowingEmptyState == false
+ }
+
+ #expect(transport.renderedStateForTesting.selection == .chat(selectedChatID.rawValue))
+ #expect(snapshot.log.isEmpty == false)
+ }
+
+ @Test func previewContentViewControllerRendersSidebarFromFakeAppServer() async throws {
+ let viewController = makeReviewMonitorPreviewContentViewControllerForPreview()
+
+ viewController.prepareForSwiftUIPreviewRendering()
+
+ let sidebar = viewController.splitViewControllerForTesting.sidebarViewControllerForTesting
+ try await waitForCondition {
+ sidebar.sidebarKindForTesting == .chatList
+ && sidebar.displayedCodexSidebarTitlesForTesting.contains("workspace-alpha")
+ && sidebar.displayedCodexSidebarTitlesForTesting.contains("Branch: feature/workspace-alpha-sidebar")
+ }
+
+ #expect(sidebar.isShowingEmptyStateForTesting == false)
+ #expect(sidebar.sidebarKindForTesting == .chatList)
+ }
+
+ @Test func previewChatContextMenuCancelInterruptsActiveFakeAppServerChat() async throws {
+ let previewContent = ReviewMonitorPreviewContent.makeContentSource()
+ let store = previewContent.store
+ let selectedChatID = try #require(previewSelectedChatID(in: store))
+ let viewController = makeReviewMonitorPreviewContentViewControllerForPreview(
+ previewContent: previewContent
+ )
+ let window = NSWindow(contentViewController: viewController)
+ defer { window.close() }
+ window.setContentSize(NSSize(width: 900, height: 600))
+ viewController.loadViewIfNeeded()
+ viewController.view.layoutSubtreeIfNeeded()
+
+ let splitViewController = viewController.splitViewControllerForTesting
+ let sidebar = splitViewController.sidebarViewControllerForTesting
+ let transport = splitViewController.transportViewControllerForTesting
+ try await waitForCondition {
+ sidebar.sidebarKindForTesting == .chatList
+ && sidebar.displayedCodexSidebarTitlesForTesting.contains("Branch: feature/workspace-alpha-sidebar")
+ }
+ splitViewController.setSidebarReviewChatFilterForTesting(.running)
+ try await waitForCondition {
+ sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(selectedChatID))
+ == "Branch: feature/workspace-alpha-sidebar"
+ && sidebar.selectedReviewChatIDForTesting == selectedChatID
+ && sidebar.nativeSelectedReviewChatIDForTesting == selectedChatID
+ && transport.renderedStateForTesting.selection == .chat(selectedChatID.rawValue)
+ && transport.renderedStateForTesting.snapshot.isShowingEmptyState == false
+ }
+ #expect(
+ store.chatCancellationCapability(
+ forChatID: selectedChatID.rawValue,
+ isChatActive: true
+ ) == .directChat
+ )
+
+ var presentedCancelItem = false
+ var cancelItemWasEnabled = false
+ sidebar.presentContextMenuForTesting(chatID: selectedChatID) { menu in
+ guard let cancelIndex = menu.items.firstIndex(where: { $0.title == "Cancel" }) else {
+ return
+ }
+ presentedCancelItem = true
+ cancelItemWasEnabled = menu.items[cancelIndex].isEnabled
+ menu.performActionForItem(at: cancelIndex)
+ }
+
+ #expect(presentedCancelItem)
+ #expect(cancelItemWasEnabled)
+ try await withTestTimeout(.seconds(2)) {
+ while await previewContent.interruptRequestCountForTesting() == 0 {
+ try Task.checkCancellation()
+ await Task.yield()
+ }
+ }
+ #expect(await previewContent.interruptRequestCountForTesting() == 1)
+ try await withTestTimeout(.seconds(2)) {
+ while true {
+ let snapshot = await previewContent.snapshotForTesting(chatID: selectedChatID)
+ if snapshot?.turns?.last?.status == .cancelled {
+ break
+ }
+ try Task.checkCancellation()
+ await Task.yield()
+ }
+ }
+ try await waitForCondition {
+ sidebar.codexSidebarSectionsForTesting.chat(id: selectedChatID)?.status == .idle
+ && sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(selectedChatID)) == nil
+ && sidebar.selectedReviewChatIDForTesting == selectedChatID
+ && sidebar.nativeSelectedReviewChatIDForTesting == nil
+ && transport.renderedStateForTesting.selection == .chat(selectedChatID.rawValue)
+ && transport.renderedStateForTesting.snapshot.isShowingEmptyState == false
+ }
+
+ splitViewController.setSidebarReviewChatFilterForTesting(.all)
+ try await waitForCondition {
+ sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(selectedChatID))
+ == "Branch: feature/workspace-alpha-sidebar"
+ && sidebar.selectedReviewChatIDForTesting == selectedChatID
+ && sidebar.nativeSelectedReviewChatIDForTesting == selectedChatID
+ }
+
+ var presentedCancelItemAfterCancellation = false
+ var cancelItemEnabledAfterCancellation = false
+ sidebar.presentContextMenuForTesting(chatID: selectedChatID) { menu in
+ guard let cancelItem = menu.items.first(where: { $0.title == "Cancel" }) else {
+ return
+ }
+ presentedCancelItemAfterCancellation = true
+ cancelItemEnabledAfterCancellation = cancelItem.isEnabled
+ }
+ #expect(presentedCancelItemAfterCancellation)
+ #expect(cancelItemEnabledAfterCancellation == false)
+ }
+
+ @Test func inactiveChatContextMenuArchiveSkipsConfirmationAndRemovesSidebarChat() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let context = CodexModelContainer(appServer: runtime.server).mainContext
+ let repo = try makeShellTestGitRepository()
+ let chatID = CodexThreadID(rawValue: "inactive-chat-to-archive")
+
+ try await runtime.transport.enqueueThreadList(
+ .init(
+ threads: [
+ .init(
+ id: chatID,
+ workspace: repo,
+ name: "Inactive archive chat",
+ updatedAt: Date(timeIntervalSince1970: 5_000),
+ status: .idle
+ )
+ ]
+ ))
+ try await runtime.transport.enqueueEmpty(for: "thread/archive")
+
+ let store = CodexReviewStore.makePreviewStore()
+ store.loadForTesting(serverState: .running)
+ let viewController = ReviewMonitorSplitViewController(
+ store: store,
+ uiState: ReviewMonitorUIState(auth: store.auth),
+ modelContext: context
+ )
+ let window = NSWindow(contentViewController: viewController)
+ defer { window.close() }
+ window.setContentSize(NSSize(width: 900, height: 600))
+ viewController.loadViewIfNeeded()
+ viewController.view.layoutSubtreeIfNeeded()
+
+ let sidebar = viewController.sidebarViewControllerForTesting
+ try await waitForCondition {
+ sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(chatID)) == "Inactive archive chat"
+ }
+
+ var confirmationRequestCount = 0
+ sidebar.setChatArchiveConfirmationForTesting { _, _ in
+ confirmationRequestCount += 1
+ return false
+ }
+
+ let archiveMenuState = performChatContextMenuItemForTesting(
+ title: "Archive",
+ chatID: chatID,
+ sidebar: sidebar
+ )
+
+ #expect(archiveMenuState.presented)
+ #expect(archiveMenuState.enabled)
+ await runtime.transport.waitForRequest(method: "thread/archive")
+ try await waitForCondition {
+ sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(chatID)) == nil
+ }
+ #expect(await runtime.transport.recordedRequests(method: "thread/archive").count == 1)
+ #expect(confirmationRequestCount == 0)
+ }
+
+ @Test func activeChatContextMenuArchiveDoesNotArchiveWhenConfirmationIsRejected() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let context = CodexModelContainer(appServer: runtime.server).mainContext
+ let repo = try makeShellTestGitRepository()
+ let chatID = CodexThreadID(rawValue: "active-chat-archive-rejected")
+
+ try await runtime.transport.enqueueThreadList(
+ .init(
+ threads: [
+ .init(
+ id: chatID,
+ workspace: repo,
+ name: "Rejected active archive chat",
+ updatedAt: Date(timeIntervalSince1970: 5_000),
+ status: .active(activeFlags: [])
+ )
+ ]
+ ))
+
+ let store = CodexReviewStore.makePreviewStore()
+ store.loadForTesting(serverState: .running)
+ let viewController = ReviewMonitorSplitViewController(
+ store: store,
+ uiState: ReviewMonitorUIState(auth: store.auth),
+ modelContext: context
+ )
+ let window = NSWindow(contentViewController: viewController)
+ defer { window.close() }
+ window.setContentSize(NSSize(width: 900, height: 600))
+ viewController.loadViewIfNeeded()
+ viewController.view.layoutSubtreeIfNeeded()
+
+ let sidebar = viewController.sidebarViewControllerForTesting
+ try await waitForCondition {
+ sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(chatID)) == "Rejected active archive chat"
+ }
+
+ var confirmedChatIDs: [CodexThreadID] = []
+ sidebar.setChatArchiveConfirmationForTesting { chatID, _ in
+ confirmedChatIDs.append(chatID)
+ return false
+ }
+
+ let archiveMenuState = performChatContextMenuItemForTesting(
+ title: "Archive",
+ chatID: chatID,
+ sidebar: sidebar
+ )
+
+ #expect(archiveMenuState.presented)
+ #expect(archiveMenuState.enabled)
+ try await waitForCondition {
+ confirmedChatIDs == [chatID]
+ }
+ #expect(await runtime.transport.recordedRequests(method: "thread/archive").isEmpty)
+ #expect(sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(chatID)) == "Rejected active archive chat")
+ }
+
+ @Test func activeChatContextMenuArchiveArchivesAfterConfirmationApproval() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let context = CodexModelContainer(appServer: runtime.server).mainContext
+ let repo = try makeShellTestGitRepository()
+ let chatID = CodexThreadID(rawValue: "active-chat-archive-approved")
+
+ try await runtime.transport.enqueueThreadList(
+ .init(
+ threads: [
+ .init(
+ id: chatID,
+ workspace: repo,
+ name: "Approved active archive chat",
+ updatedAt: Date(timeIntervalSince1970: 5_000),
+ status: .active(activeFlags: [])
+ )
+ ]
+ ))
+ try await runtime.transport.enqueueEmpty(for: "thread/archive")
+
+ let store = CodexReviewStore.makePreviewStore()
+ store.loadForTesting(serverState: .running)
+ let viewController = ReviewMonitorSplitViewController(
+ store: store,
+ uiState: ReviewMonitorUIState(auth: store.auth),
+ modelContext: context
+ )
+ let window = NSWindow(contentViewController: viewController)
+ defer { window.close() }
+ window.setContentSize(NSSize(width: 900, height: 600))
+ viewController.loadViewIfNeeded()
+ viewController.view.layoutSubtreeIfNeeded()
+
+ let sidebar = viewController.sidebarViewControllerForTesting
+ try await waitForCondition {
+ sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(chatID)) == "Approved active archive chat"
+ }
+
+ var confirmedChatIDs: [CodexThreadID] = []
+ sidebar.setChatArchiveConfirmationForTesting { chatID, _ in
+ confirmedChatIDs.append(chatID)
+ return true
+ }
+
+ let archiveMenuState = performChatContextMenuItemForTesting(
+ title: "Archive",
+ chatID: chatID,
+ sidebar: sidebar
+ )
+
+ #expect(archiveMenuState.presented)
+ #expect(archiveMenuState.enabled)
+ await runtime.transport.waitForRequest(method: "thread/archive")
+ try await waitForCondition {
+ sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(chatID)) == nil
+ }
+ #expect(await runtime.transport.recordedRequests(method: "thread/archive").count == 1)
+ #expect(confirmedChatIDs == [chatID])
+ }
+
+ @Test func previewChatContextMenuArchiveUsesFakeAppServerThreadArchivePath() async throws {
+ let previewContent = ReviewMonitorPreviewContent.makeContentSource()
+ let store = previewContent.store
+ let selectedChatID = try #require(previewSelectedChatID(in: store))
+ let viewController = makeReviewMonitorPreviewContentViewControllerForPreview(
+ previewContent: previewContent
+ )
+ let window = NSWindow(contentViewController: viewController)
+ defer { window.close() }
+ window.setContentSize(NSSize(width: 900, height: 600))
+ viewController.loadViewIfNeeded()
+ viewController.view.layoutSubtreeIfNeeded()
+
+ let sidebar = viewController.splitViewControllerForTesting.sidebarViewControllerForTesting
+ try await waitForCondition {
+ sidebar.sidebarKindForTesting == .chatList
+ && sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(selectedChatID)) != nil
+ }
+
+ var confirmedChatIDs: [CodexThreadID] = []
+ sidebar.setChatArchiveConfirmationForTesting { chatID, _ in
+ confirmedChatIDs.append(chatID)
+ return true
+ }
+
+ let archiveMenuState = performChatContextMenuItemForTesting(
+ title: "Archive",
+ chatID: selectedChatID,
+ sidebar: sidebar
+ )
+
+ #expect(archiveMenuState.presented)
+ #expect(archiveMenuState.enabled)
+ try await withTestTimeout(.seconds(2)) {
+ while await previewContent.archiveRequestCountForTesting() == 0 {
+ try Task.checkCancellation()
+ await Task.yield()
+ }
+ }
+ try await waitForCondition {
+ sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(selectedChatID)) == nil
+ }
+ #expect(await previewContent.archiveRequestCountForTesting() == 1)
+ #expect(confirmedChatIDs == [selectedChatID])
+ }
+
+ @Test func activeChatContextMenuCancelFallsBackWhenMatchingReviewRunIsTerminal() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let context = CodexModelContainer(appServer: runtime.server).mainContext
+ let repo = try makeShellTestGitRepository()
+ let chatID = CodexThreadID(rawValue: "active-chat-with-terminal-run")
+ let turnID = CodexTurnID(rawValue: "active-turn")
+
+ try await runtime.transport.enqueueThreadList(
+ .init(
+ threads: [
+ .init(
+ id: chatID,
+ workspace: repo,
+ name: "Active chat",
+ updatedAt: Date(timeIntervalSince1970: 5_000),
+ status: .active(activeFlags: []),
+ turns: [
+ .init(id: turnID, status: .running)
+ ]
+ )
+ ]
+ ))
+ try await runtime.transport.enqueueThreadResume(
+ .init(
+ id: chatID,
+ status: .active(activeFlags: []),
+ turns: [
+ .init(id: turnID, status: .running)
+ ]
+ ))
+ try await runtime.transport.enqueueEmpty(for: "turn/interrupt")
+
+ let terminalRun = ReviewRunRecord.makeForTesting(
+ id: "terminal-run",
+ cwd: repo.path,
+ targetSummary: "Terminal review run",
+ threadID: chatID.rawValue,
+ turnID: "terminal-turn",
+ status: .succeeded,
+ startedAt: Date(timeIntervalSince1970: 4_000),
+ endedAt: Date(timeIntervalSince1970: 4_100),
+ summary: "Completed review."
+ )
+ let store = CodexReviewStore.makePreviewStore()
+ store.loadReviewCancellationStateForTesting(
+ serverState: .running,
+ reviewRuns: [terminalRun]
+ )
+ let viewController = ReviewMonitorSplitViewController(
+ store: store,
+ uiState: ReviewMonitorUIState(auth: store.auth),
+ modelContext: context
+ )
+ let window = NSWindow(contentViewController: viewController)
+ defer { window.close() }
+ window.setContentSize(NSSize(width: 900, height: 600))
+ viewController.loadViewIfNeeded()
+ viewController.view.layoutSubtreeIfNeeded()
+
+ let sidebar = viewController.sidebarViewControllerForTesting
+ try await waitForCondition {
+ sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(chatID)) == "Active chat"
+ }
+
+ #expect(
+ store.chatCancellationCapability(
+ forChatID: chatID.rawValue,
+ isChatActive: true
+ ) == .directChat
+ )
+
+ var presentedCancelItem = false
+ var cancelItemWasEnabled = false
+ sidebar.presentContextMenuForTesting(chatID: chatID) { menu in
+ guard let cancelIndex = menu.items.firstIndex(where: { $0.title == "Cancel" }) else {
+ return
+ }
+ presentedCancelItem = true
+ cancelItemWasEnabled = menu.items[cancelIndex].isEnabled
+ menu.performActionForItem(at: cancelIndex)
+ }
+
+ #expect(presentedCancelItem)
+ #expect(cancelItemWasEnabled)
+ await runtime.transport.waitForRequest(method: "turn/interrupt")
+ let interruptRequestCount = await runtime.transport.recordedRequests(method: "turn/interrupt").count
+ #expect(interruptRequestCount == 1)
+ }
+
+ @Test func activeChatContextMenuCancelDoesNotBypassPendingReviewCancellation() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let context = CodexModelContainer(appServer: runtime.server).mainContext
+ let repo = try makeShellTestGitRepository()
+ let chatID = CodexThreadID(rawValue: "active-chat-with-pending-run-cancel")
+ let turnID = CodexTurnID(rawValue: "pending-turn")
+
+ try await runtime.transport.enqueueThreadList(
+ .init(
+ threads: [
+ .init(
+ id: chatID,
+ workspace: repo,
+ name: "Pending cancellation chat",
+ updatedAt: Date(timeIntervalSince1970: 5_000),
+ status: .active(activeFlags: []),
+ turns: [
+ .init(id: turnID, status: .running)
+ ]
+ )
+ ]
+ ))
+ try await runtime.transport.enqueueThreadResume(
+ .init(
+ id: chatID,
+ status: .active(activeFlags: []),
+ turns: [
+ .init(id: turnID, status: .running)
+ ]
+ ))
+ try await runtime.transport.enqueueEmpty(for: "turn/interrupt")
+
+ let terminalRun = ReviewRunRecord.makeForTesting(
+ id: "a-terminal-run",
+ cwd: repo.path,
+ targetSummary: "Terminal review run",
+ threadID: chatID.rawValue,
+ turnID: "terminal-review-turn",
+ status: .succeeded,
+ startedAt: Date(timeIntervalSince1970: 4_100),
+ endedAt: Date(timeIntervalSince1970: 4_200),
+ summary: "Completed review."
+ )
+ let pendingRun = ReviewRunRecord.makeForTesting(
+ id: "z-pending-cancellation-run",
+ cwd: repo.path,
+ targetSummary: "Pending cancellation review run",
+ threadID: chatID.rawValue,
+ turnID: "pending-review-turn",
+ status: .running,
+ cancellationRequested: true,
+ startedAt: Date(timeIntervalSince1970: 4_000),
+ summary: "Cancellation requested."
+ )
+ let store = CodexReviewStore.makePreviewStore()
+ store.loadReviewCancellationStateForTesting(
+ serverState: .running,
+ reviewRuns: [terminalRun, pendingRun]
+ )
+ let viewController = ReviewMonitorSplitViewController(
+ store: store,
+ uiState: ReviewMonitorUIState(auth: store.auth),
+ modelContext: context
+ )
+ let window = NSWindow(contentViewController: viewController)
+ defer { window.close() }
+ window.setContentSize(NSSize(width: 900, height: 600))
+ viewController.loadViewIfNeeded()
+ viewController.view.layoutSubtreeIfNeeded()
+
+ let sidebar = viewController.sidebarViewControllerForTesting
+ try await waitForCondition {
+ sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(chatID)) == "Pending cancellation chat"
+ }
+
+ #expect(
+ store.chatCancellationCapability(
+ forChatID: chatID.rawValue,
+ isChatActive: true
+ ) == .pendingReviewCancellation
+ )
+
+ var cancelItemWasEnabled = false
+ sidebar.presentContextMenuForTesting(chatID: chatID) { menu in
+ guard let cancelIndex = menu.items.firstIndex(where: { $0.title == "Cancel" }) else {
+ return
+ }
+ cancelItemWasEnabled = menu.items[cancelIndex].isEnabled
+ menu.performActionForItem(at: cancelIndex)
+ }
+
+ // A pending cancellation leaves nothing further to trigger, so the
+ // command is visible but disabled.
+ #expect(cancelItemWasEnabled == false)
+ try await Task.sleep(for: .milliseconds(100))
+ let resumeRequestCount = await runtime.transport.recordedRequests(method: "thread/resume").count
+ let interruptRequestCount = await runtime.transport.recordedRequests(method: "turn/interrupt").count
+ #expect(resumeRequestCount == 0)
+ #expect(interruptRequestCount == 0)
+ }
+
+ @Test func previewContentViewControllerRendersSelectedChatLogDuringViewLifecycle() async throws {
+ let previewContent = ReviewMonitorPreviewContent.makeContentSource()
+ let store = previewContent.store
+ let selectedChatID = try #require(previewSelectedChatID(in: store))
+ let selectedSnapshot = try #require(await previewContent.snapshotForTesting(chatID: selectedChatID))
+ let expectedLogText = try #require(selectedSnapshot.items.compactMap { $0.text }.first)
+ let viewController = makeReviewMonitorPreviewContentViewControllerForPreview(
+ previewContent: previewContent
+ )
+
+ viewController.loadViewIfNeeded()
+ viewController.view.layoutSubtreeIfNeeded()
+
+ let transport = viewController.splitViewControllerForTesting.transportViewControllerForTesting
+ let snapshot = try await awaitTransportRender(
+ transport,
+ expectedSelection: .chat(selectedChatID.rawValue)
+ ) { snapshot in
+ snapshot.log.contains(expectedLogText) && snapshot.isShowingEmptyState == false
+ }
+
+ #expect(transport.renderedStateForTesting.selection == .chat(selectedChatID.rawValue))
+ #expect(snapshot.log.isEmpty == false)
+ }
+
+ @Test func previewContentViewControllerStreamsSelectedChatLogDuringViewLifecycle() async throws {
+ let previewContent = ReviewMonitorPreviewContent.makeContentSource()
+ let store = previewContent.store
+ let selectedChatID = try #require(previewSelectedChatID(in: store))
+ let viewController = makeReviewMonitorPreviewContentViewControllerForPreview(
+ previewContent: previewContent
+ )
+
+ viewController.loadViewIfNeeded()
+ viewController.view.layoutSubtreeIfNeeded()
+
+ let transport = viewController.splitViewControllerForTesting.transportViewControllerForTesting
+ let initialSnapshot = try await awaitTransportRender(
+ transport,
+ expectedSelection: .chat(selectedChatID.rawValue)
+ ) { snapshot in
+ snapshot.log.isEmpty == false && snapshot.isShowingEmptyState == false
+ }
+
+ let nextTick = try #require(await viewController.appendPreviewChatLogStreamTickForTesting())
+ #expect(nextTick == 1)
+ let updatedSnapshot = try await awaitTransportRender(
+ transport,
+ expectedSelection: .chat(selectedChatID.rawValue)
+ ) { snapshot in
+ snapshot.log.count > initialSnapshot.log.count
+ && snapshot.log.contains("Turn started")
+ && snapshot.isShowingEmptyState == false
+ }
+
+ #expect(updatedSnapshot.log.contains("Turn started"))
+ }
+
@Test func bindingStoreAppliesInitialState() {
let store = CodexReviewStore.makePreviewStore()
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
viewController.loadViewIfNeeded()
#expect(viewController.sidebarTopAccessoryCountForTesting == 0)
@@ -32,14 +648,14 @@ extension ReviewUITests {
#expect(viewController.contentPaneViewControllerForTesting.isShowingEmptyStateForTesting)
}
- @Test func splitViewShowsEmptyStateWithoutJobs() {
+ @Test func splitViewShowsEmptyStateWithoutChats() {
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
- serverURL: URL(string: "http://localhost:9417/mcp"),
- workspaces: []
+ serverURL: URL(string: "http://localhost:9417/mcp")
)
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
viewController.loadViewIfNeeded()
#expect(viewController.splitViewItems.count == 2)
@@ -53,10 +669,10 @@ extension ReviewUITests {
@Test func splitViewShowsUnavailableSidebarWhenServerFailedOnLoad() {
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
- serverState: .failed("Embedded server is unavailable in preview mode."),
- workspaces: []
+ serverState: .failed("Embedded server is unavailable in preview mode.")
)
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
viewController.loadViewIfNeeded()
#expect(viewController.splitViewItems.count == 2)
@@ -68,10 +684,10 @@ extension ReviewUITests {
@Test func splitViewShowsUnavailableSidebarWhenServerStartingOnLoad() {
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
- serverState: .starting,
- workspaces: []
+ serverState: .starting
)
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
viewController.loadViewIfNeeded()
#expect(viewController.sidebarPresentationForTesting == .unavailable)
@@ -80,26 +696,26 @@ extension ReviewUITests {
@Test func splitViewShowsUnavailableSidebarWhenServerStoppedOnLoad() {
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
- serverState: .stopped,
- workspaces: []
+ serverState: .stopped
)
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
viewController.loadViewIfNeeded()
#expect(viewController.sidebarPresentationForTesting == .unavailable)
}
- @Test func splitViewShowsJobSidebarWhenServerRunningOnLoad() {
+ @Test func splitViewShowsReviewChatSidebarWhenServerRunningOnLoad() {
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
- serverURL: URL(string: "http://localhost:9417/mcp"),
- workspaces: []
+ serverURL: URL(string: "http://localhost:9417/mcp")
)
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
viewController.loadViewIfNeeded()
- #expect(viewController.sidebarPresentationForTesting == .jobList)
+ #expect(viewController.sidebarPresentationForTesting == .chatList)
#expect(viewController.sidebarTopAccessoryCountForTesting == 0)
#expect(viewController.sidebarAccessoryCountForTesting == 1)
}
@@ -108,14 +724,13 @@ extension ReviewUITests {
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
- serverURL: URL(string: "http://localhost:9417/mcp"),
- workspaces: []
+ serverURL: URL(string: "http://localhost:9417/mcp")
)
let uiState = ReviewMonitorUIState(auth: store.auth)
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(store: store, uiState: uiState)
viewController.loadViewIfNeeded()
- #expect(viewController.sidebarPresentationForTesting == .jobList)
+ #expect(viewController.sidebarPresentationForTesting == .chatList)
#expect(viewController.sidebarBottomAccessoryIsHiddenForTesting == false)
uiState.sidebarSelection = .account
@@ -124,7 +739,7 @@ extension ReviewUITests {
uiState.sidebarSelection = .workspace
try await waitForSidebarBottomAccessoryHidden(viewController, false)
- #expect(viewController.sidebarPresentationForTesting == .jobList)
+ #expect(viewController.sidebarPresentationForTesting == .chatList)
}
@Test func statusAccessoryViewControllerVisibilityTracksOnlySidebarSelection() async throws {
@@ -139,8 +754,7 @@ extension ReviewUITests {
#expect(viewController.isHidden == false)
store.loadForTesting(
- serverState: .failed("Embedded server is unavailable in preview mode."),
- workspaces: []
+ serverState: .failed("Embedded server is unavailable in preview mode.")
)
#expect(viewController.isHidden == false)
@@ -201,9 +815,7 @@ extension ReviewUITests {
}
@Test func sidebarScrollViewExtendsBehindBottomAccessory() {
- let store = ReviewMonitorPreviewContent.makeStore(
- streamInterval: nil
- )
+ let store = ReviewMonitorPreviewContent.makeStore()
let harness = makeWindowHarness(
store: store,
contentSize: NSSize(width: 760, height: 420)
@@ -228,17 +840,16 @@ extension ReviewUITests {
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
- serverURL: URL(string: "http://localhost:9417/mcp"),
- workspaces: []
+ serverURL: URL(string: "http://localhost:9417/mcp")
)
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
viewController.loadViewIfNeeded()
- #expect(viewController.sidebarPresentationForTesting == .jobList)
+ #expect(viewController.sidebarPresentationForTesting == .chatList)
store.loadForTesting(
- serverState: .failed("Embedded server is unavailable in preview mode."),
- workspaces: []
+ serverState: .failed("Embedded server is unavailable in preview mode.")
)
try await waitForSidebarPresentation(
viewController,
@@ -249,12 +860,11 @@ extension ReviewUITests {
store.loadForTesting(
serverState: .running,
- serverURL: URL(string: "http://localhost:9417/mcp"),
- workspaces: []
+ serverURL: URL(string: "http://localhost:9417/mcp")
)
try await waitForSidebarPresentation(
viewController,
- .jobList,
+ .chatList,
observation: viewController.sidebarViewControllerForTesting.sidebarKindObservationForTesting
)
#expect(viewController.sidebarAccessoryCountForTesting == 1)
@@ -269,18 +879,21 @@ extension ReviewUITests {
let sidebarItem = try #require(viewController.splitViewItems.first)
sidebarItem.isCollapsed = false
try await waitForCondition {
- viewController.sidebarJobFilterToolbarItemIsHiddenForTesting == false
+ viewController.sidebarReviewChatFilterToolbarItemIsHiddenForTesting == false
}
#expect(window.toolbar != nil)
#expect(harness.rootViewController.contentKindForTesting == .contentView)
- #expect(viewController.toolbarIdentifiersForTesting.contains(viewController.sidebarPickerToolbarItemIdentifierForTesting))
- #expect(viewController.toolbarIdentifiersForTesting.contains(viewController.sidebarJobFilterToolbarItemIdentifierForTesting))
+ #expect(
+ viewController.toolbarIdentifiersForTesting.contains(
+ viewController.sidebarPickerToolbarItemIdentifierForTesting))
+ #expect(
+ viewController.toolbarIdentifiersForTesting.contains(
+ viewController.sidebarReviewChatFilterToolbarItemIdentifierForTesting))
#expect(viewController.toolbarIdentifiersForTesting.contains(.toggleSidebar) == false)
#expect(viewController.toolbarIdentifiersForTesting.contains(.sidebarTrackingSeparator))
#expect(
- viewController.sidebarPickerToolbarSegmentAccessibilityDescriptionsForTesting ==
- ["Workspace", "Account"]
+ viewController.sidebarPickerToolbarSegmentAccessibilityDescriptionsForTesting == ["Workspace", "Account"]
)
#expect(window.styleMask.contains(.fullSizeContentView))
#expect(window.titleVisibility == .hidden)
@@ -293,7 +906,7 @@ extension ReviewUITests {
#expect(viewController.contentAutomaticallyAdjustsSafeAreaInsetsForTesting)
}
- @Test func sidebarJobFilterToolbarItemProvidesMenuAndSelectedState() async throws {
+ @Test func sidebarReviewChatFilterToolbarItemProvidesMenuAndSelectedState() async throws {
let store = CodexReviewStore.makePreviewStore()
let harness = makeWindowHarness(store: store)
let viewController = harness.viewController
@@ -302,81 +915,83 @@ extension ReviewUITests {
let sidebarItem = try #require(viewController.splitViewItems.first)
sidebarItem.isCollapsed = false
- #expect(viewController.sidebarJobFilterToolbarItemIsHiddenForTesting == false)
- #expect(viewController.sidebarJobFilterToolbarMenuItemTitlesForTesting == [
- "All Items",
- "-",
- "Running",
- "Latest Finished",
- ])
- #expect(viewController.sidebarJobFilterToolbarShowsActiveBackgroundForTesting == false)
+ #expect(viewController.sidebarReviewChatFilterToolbarItemIsHiddenForTesting == false)
+ #expect(
+ viewController.sidebarReviewChatFilterToolbarMenuItemTitlesForTesting == [
+ "All Items",
+ "-",
+ "Running",
+ "Latest Finished",
+ ])
+ #expect(viewController.sidebarReviewChatFilterToolbarShowsActiveBackgroundForTesting == false)
#expect(viewController.selectedToolbarItemIdentifierForTesting == nil)
- viewController.setSidebarJobFilterForTesting(.running)
+ viewController.setSidebarReviewChatFilterForTesting(.running)
try await waitForCondition {
- viewController.sidebarJobFilterToolbarShowsActiveBackgroundForTesting
+ viewController.sidebarReviewChatFilterToolbarShowsActiveBackgroundForTesting
}
- #expect(viewController.sidebarJobFilterToolbarSelectedFilterForTesting == .running)
- #expect(viewController.sidebarJobFilterToolbarSelectedMenuItemTitlesForTesting == ["Running"])
+ #expect(viewController.sidebarReviewChatFilterToolbarSelectedFilterForTesting == .running)
+ #expect(viewController.sidebarReviewChatFilterToolbarSelectedMenuItemTitlesForTesting == ["Running"])
#expect(viewController.selectedToolbarItemIdentifierForTesting == nil)
- viewController.selectSidebarJobFilterForTesting(.latestFinished)
- let combinedFilter: SidebarJobFilter = [.running, .latestFinished]
+ viewController.selectSidebarReviewChatFilterForTesting(.latestFinished)
+ let combinedFilter: SidebarReviewChatFilter = [.running, .latestFinished]
try await waitForCondition {
- viewController.sidebarJobFilterToolbarSelectedFilterForTesting == combinedFilter
+ viewController.sidebarReviewChatFilterToolbarSelectedFilterForTesting == combinedFilter
}
- #expect(viewController.sidebarJobFilterToolbarShowsActiveBackgroundForTesting)
- #expect(viewController.sidebarJobFilterToolbarSelectedMenuItemTitlesForTesting == ["Running", "Latest Finished"])
+ #expect(viewController.sidebarReviewChatFilterToolbarShowsActiveBackgroundForTesting)
+ #expect(
+ viewController.sidebarReviewChatFilterToolbarSelectedMenuItemTitlesForTesting == ["Running", "Latest Finished"])
#expect(viewController.selectedToolbarItemIdentifierForTesting == nil)
- viewController.selectSidebarJobFilterForTesting(.running)
+ viewController.selectSidebarReviewChatFilterForTesting(.running)
try await waitForCondition {
- viewController.sidebarJobFilterToolbarSelectedFilterForTesting == .latestFinished
+ viewController.sidebarReviewChatFilterToolbarSelectedFilterForTesting == .latestFinished
}
- #expect(viewController.sidebarJobFilterToolbarShowsActiveBackgroundForTesting)
- #expect(viewController.sidebarJobFilterToolbarSelectedMenuItemTitlesForTesting == ["Latest Finished"])
+ #expect(viewController.sidebarReviewChatFilterToolbarShowsActiveBackgroundForTesting)
+ #expect(viewController.sidebarReviewChatFilterToolbarSelectedMenuItemTitlesForTesting == ["Latest Finished"])
#expect(viewController.selectedToolbarItemIdentifierForTesting == nil)
- viewController.setSidebarJobFilterForTesting(.all)
+ viewController.setSidebarReviewChatFilterForTesting(.all)
try await waitForCondition {
- viewController.sidebarJobFilterToolbarShowsActiveBackgroundForTesting == false
+ viewController.sidebarReviewChatFilterToolbarShowsActiveBackgroundForTesting == false
}
- #expect(viewController.sidebarJobFilterToolbarSelectedFilterForTesting == .all)
- #expect(viewController.sidebarJobFilterToolbarSelectedMenuItemTitlesForTesting == ["All Items"])
+ #expect(viewController.sidebarReviewChatFilterToolbarSelectedFilterForTesting == .all)
+ #expect(viewController.sidebarReviewChatFilterToolbarSelectedMenuItemTitlesForTesting == ["All Items"])
#expect(viewController.selectedToolbarItemIdentifierForTesting == nil)
}
- @Test func sidebarJobFilterPersistsMenuSelectionAcrossWindowControllers() async throws {
- let defaultsContext = try makeSidebarJobFilterDefaultsForTesting()
+ @Test func sidebarReviewChatFilterPersistsMenuSelectionAcrossWindowControllers() async throws {
+ let defaultsContext = try makeSidebarReviewChatFilterDefaultsForTesting()
let defaults = defaultsContext.defaults
defer {
defaults.removePersistentDomain(forName: defaultsContext.suiteName)
}
- let combinedFilter: SidebarJobFilter = [.running, .latestFinished]
+ let combinedFilter: SidebarReviewChatFilter = [.running, .latestFinished]
do {
let store = CodexReviewStore.makePreviewStore()
let harness = makeWindowHarness(
store: store,
- sidebarJobFilterDefaults: defaults
+ sidebarReviewChatFilterDefaults: defaults
)
let viewController = harness.viewController
let sidebarItem = try #require(viewController.splitViewItems.first)
sidebarItem.isCollapsed = false
try await waitForCondition {
- viewController.sidebarJobFilterToolbarSelectedFilterForTesting == .all
+ viewController.sidebarReviewChatFilterToolbarSelectedFilterForTesting == .all
}
- viewController.selectSidebarJobFilterForTesting(.running)
+ viewController.selectSidebarReviewChatFilterForTesting(.running)
try await waitForCondition {
- viewController.sidebarJobFilterToolbarSelectedFilterForTesting == .running
+ viewController.sidebarReviewChatFilterToolbarSelectedFilterForTesting == .running
}
- viewController.selectSidebarJobFilterForTesting(.latestFinished)
+ viewController.selectSidebarReviewChatFilterForTesting(.latestFinished)
try await waitForCondition {
- viewController.sidebarJobFilterToolbarSelectedFilterForTesting == combinedFilter
+ viewController.sidebarReviewChatFilterToolbarSelectedFilterForTesting == combinedFilter
}
#expect(
- defaults.string(forKey: ReviewMonitorSidebar.JobFilterPersistence.defaultsKey)
+ defaults.string(forKey: ReviewMonitorSidebar.ReviewChatFilterPersistence.defaultsKey)
== combinedFilter.persistedValue
)
harness.window.close()
@@ -386,22 +1001,22 @@ extension ReviewUITests {
let store = CodexReviewStore.makePreviewStore()
let harness = makeWindowHarness(
store: store,
- sidebarJobFilterDefaults: defaults
+ sidebarReviewChatFilterDefaults: defaults
)
let viewController = harness.viewController
let sidebarItem = try #require(viewController.splitViewItems.first)
sidebarItem.isCollapsed = false
try await waitForCondition {
- viewController.sidebarJobFilterToolbarSelectedFilterForTesting == combinedFilter
+ viewController.sidebarReviewChatFilterToolbarSelectedFilterForTesting == combinedFilter
}
- viewController.selectSidebarJobFilterForTesting(.all)
+ viewController.selectSidebarReviewChatFilterForTesting(.all)
try await waitForCondition {
- viewController.sidebarJobFilterToolbarSelectedFilterForTesting == .all
+ viewController.sidebarReviewChatFilterToolbarSelectedFilterForTesting == .all
}
#expect(
- defaults.string(forKey: ReviewMonitorSidebar.JobFilterPersistence.defaultsKey)
- == SidebarJobFilter.all.persistedValue
+ defaults.string(forKey: ReviewMonitorSidebar.ReviewChatFilterPersistence.defaultsKey)
+ == SidebarReviewChatFilter.all.persistedValue
)
harness.window.close()
}
@@ -410,7 +1025,7 @@ extension ReviewUITests {
let store = CodexReviewStore.makePreviewStore()
let harness = makeWindowHarness(
store: store,
- sidebarJobFilterDefaults: defaults
+ sidebarReviewChatFilterDefaults: defaults
)
let viewController = harness.viewController
let sidebarItem = try #require(viewController.splitViewItems.first)
@@ -418,23 +1033,23 @@ extension ReviewUITests {
defer { harness.window.close() }
try await waitForCondition {
- viewController.sidebarJobFilterToolbarSelectedFilterForTesting == .all
+ viewController.sidebarReviewChatFilterToolbarSelectedFilterForTesting == .all
}
}
}
- @Test func sidebarJobFilterDefaultsToAllForInvalidPersistedValue() async throws {
- let defaultsContext = try makeSidebarJobFilterDefaultsForTesting()
+ @Test func sidebarReviewChatFilterDefaultsToAllForInvalidPersistedValue() async throws {
+ let defaultsContext = try makeSidebarReviewChatFilterDefaultsForTesting()
let defaults = defaultsContext.defaults
defer {
defaults.removePersistentDomain(forName: defaultsContext.suiteName)
}
- defaults.set("invalid-filter", forKey: ReviewMonitorSidebar.JobFilterPersistence.defaultsKey)
+ defaults.set("invalid-filter", forKey: ReviewMonitorSidebar.ReviewChatFilterPersistence.defaultsKey)
let store = CodexReviewStore.makePreviewStore()
let harness = makeWindowHarness(
store: store,
- sidebarJobFilterDefaults: defaults
+ sidebarReviewChatFilterDefaults: defaults
)
let viewController = harness.viewController
let sidebarItem = try #require(viewController.splitViewItems.first)
@@ -442,11 +1057,11 @@ extension ReviewUITests {
defer { harness.window.close() }
try await waitForCondition {
- viewController.sidebarJobFilterToolbarSelectedFilterForTesting == .all
+ viewController.sidebarReviewChatFilterToolbarSelectedFilterForTesting == .all
}
}
- @Test func sidebarJobFilterToolbarItemOnlyShowsForWorkspaceSidebar() async throws {
+ @Test func sidebarReviewChatFilterToolbarItemOnlyShowsForWorkspaceSidebar() async throws {
let store = CodexReviewStore.makePreviewStore()
let harness = makeWindowHarness(store: store)
let viewController = harness.viewController
@@ -455,16 +1070,16 @@ extension ReviewUITests {
let sidebarItem = try #require(viewController.splitViewItems.first)
sidebarItem.isCollapsed = false
- #expect(viewController.sidebarJobFilterToolbarItemIsHiddenForTesting == false)
+ #expect(viewController.sidebarReviewChatFilterToolbarItemIsHiddenForTesting == false)
viewController.selectSidebarPickerToolbarSegmentForTesting(.account)
try await waitForCondition {
- viewController.sidebarJobFilterToolbarItemIsHiddenForTesting
+ viewController.sidebarReviewChatFilterToolbarItemIsHiddenForTesting
}
viewController.selectSidebarPickerToolbarSegmentForTesting(.workspace)
try await waitForCondition {
- viewController.sidebarJobFilterToolbarItemIsHiddenForTesting == false
+ viewController.sidebarReviewChatFilterToolbarItemIsHiddenForTesting == false
}
}
@@ -472,8 +1087,7 @@ extension ReviewUITests {
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
- serverURL: URL(string: "http://localhost:9417/mcp"),
- workspaces: []
+ serverURL: URL(string: "http://localhost:9417/mcp")
)
let harness = makeWindowHarness(store: store)
let viewController = harness.viewController
@@ -482,7 +1096,7 @@ extension ReviewUITests {
let sidebarItem = try #require(viewController.splitViewItems.first)
sidebarItem.isCollapsed = false
- #expect(viewController.sidebarPresentationForTesting == .jobList)
+ #expect(viewController.sidebarPresentationForTesting == .chatList)
#expect(viewController.sidebarPickerToolbarSelectedSelectionForTesting == .workspace)
viewController.selectSidebarPickerToolbarSegmentForTesting(.account)
@@ -570,7 +1184,7 @@ extension ReviewUITests {
@Test func sidebarPickerToolbarItemTracksExternalSelectionChanges() async throws {
let store = CodexReviewStore.makePreviewStore()
let uiState = ReviewMonitorUIState(auth: store.auth)
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(store: store, uiState: uiState)
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
@@ -590,11 +1204,10 @@ extension ReviewUITests {
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
- serverURL: URL(string: "http://localhost:9417/mcp"),
- workspaces: []
+ serverURL: URL(string: "http://localhost:9417/mcp")
)
let uiState = ReviewMonitorUIState(auth: store.auth)
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(store: store, uiState: uiState)
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
viewController.attach(to: window)
@@ -610,13 +1223,12 @@ extension ReviewUITests {
uiState.sidebarSelection = .workspace
try await waitForSidebarPresentation(
viewController,
- .jobList,
+ .chatList,
observation: sidebar.sidebarKindObservationForTesting
)
store.loadForTesting(
- serverState: .failed("Embedded server is unavailable in preview mode."),
- content: makeSidebarContent(from: [])
+ serverState: .failed("Embedded server is unavailable in preview mode.")
)
try await waitForSidebarPresentation(
viewController,
@@ -626,12 +1238,11 @@ extension ReviewUITests {
store.loadForTesting(
serverState: .running,
- serverURL: URL(string: "http://localhost:9417/mcp"),
- content: makeSidebarContent(from: [])
+ serverURL: URL(string: "http://localhost:9417/mcp")
)
try await waitForSidebarPresentation(
viewController,
- .jobList,
+ .chatList,
observation: sidebar.sidebarKindObservationForTesting
)
}
@@ -639,7 +1250,7 @@ extension ReviewUITests {
@Test func splitViewShowsAddAccountToolbarItemOnlyForAccountSidebar() async throws {
let store = CodexReviewStore.makePreviewStore()
let uiState = ReviewMonitorUIState(auth: store.auth)
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(store: store, uiState: uiState)
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 900, height: 600))
@@ -665,7 +1276,7 @@ extension ReviewUITests {
let store = CodexReviewStore.makePreviewStore()
let uiState = ReviewMonitorUIState(auth: store.auth)
uiState.sidebarSelection = .account
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(store: store, uiState: uiState)
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 900, height: 600))
@@ -685,7 +1296,7 @@ extension ReviewUITests {
#expect(viewController.addAccountToolbarItemIsHiddenForTesting == false)
}
- @Test func previewContentViewControllerConfiguresAttachedWindowLikeSplitPresentation() {
+ @Test func previewContentViewControllerConfiguresAttachedWindowLikeSplitPresentation() async throws {
let viewController = makeReviewMonitorPreviewContentViewControllerForPreview()
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
@@ -695,8 +1306,6 @@ extension ReviewUITests {
#expect(window.toolbar != nil)
#expect(window.styleMask.contains(.fullSizeContentView))
- #expect(window.titleVisibility == .visible)
- #expect(window.title.isEmpty == false)
#expect(window.isOpaque)
#expect(window.backgroundColor == .windowBackgroundColor)
#expect(window.titlebarAppearsTransparent == false)
@@ -704,6 +1313,68 @@ extension ReviewUITests {
#expect(window.isMovableByWindowBackground == false)
}
+ @Test func previewContentViewControllerRendersSelectedChatLog() async throws {
+ let previewContent = ReviewMonitorPreviewContent.makeContentSource()
+ let store = previewContent.store
+ let selectedChatID = try #require(previewSelectedChatID(in: store))
+ let selectedSnapshot = try #require(await previewContent.snapshotForTesting(chatID: selectedChatID))
+ let expectedLogText = try #require(selectedSnapshot.items.compactMap { $0.text }.first)
+ let viewController = makeReviewMonitorPreviewContentViewControllerForPreview(
+ previewContent: previewContent
+ )
+ let window = NSWindow(contentViewController: viewController)
+ defer { window.close() }
+ window.setContentSize(NSSize(width: 900, height: 600))
+ viewController.loadViewIfNeeded()
+ window.layoutIfNeeded()
+
+ let transport = viewController.splitViewControllerForTesting.transportViewControllerForTesting
+ let snapshot = try await awaitTransportRender(
+ transport,
+ expectedSelection: .chat(selectedChatID.rawValue)
+ ) { snapshot in
+ snapshot.log.contains(expectedLogText) && snapshot.isShowingEmptyState == false
+ }
+
+ #expect(transport.renderedStateForTesting.selection == .chat(selectedChatID.rawValue))
+ #expect(snapshot.log.isEmpty == false)
+ }
+
+ @Test func previewContentViewControllerStreamsSelectedChatLogTicks() async throws {
+ let previewContent = ReviewMonitorPreviewContent.makeContentSource()
+ let store = previewContent.store
+ let selectedChatID = try #require(previewSelectedChatID(in: store))
+ let viewController = makeReviewMonitorPreviewContentViewControllerForPreview(
+ previewContent: previewContent
+ )
+ let window = NSWindow(contentViewController: viewController)
+ defer { window.close() }
+ window.setContentSize(NSSize(width: 900, height: 600))
+ viewController.loadViewIfNeeded()
+ window.layoutIfNeeded()
+
+ let transport = viewController.splitViewControllerForTesting.transportViewControllerForTesting
+ let initialSnapshot = try await awaitTransportRender(
+ transport,
+ expectedSelection: .chat(selectedChatID.rawValue)
+ ) { snapshot in
+ snapshot.log.isEmpty == false && snapshot.isShowingEmptyState == false
+ }
+
+ let nextTick = try #require(await viewController.appendPreviewChatLogStreamTickForTesting())
+ #expect(nextTick == 1)
+ let updatedSnapshot = try await awaitTransportRender(
+ transport,
+ expectedSelection: .chat(selectedChatID.rawValue)
+ ) { snapshot in
+ snapshot.log.count > initialSnapshot.log.count
+ && snapshot.log.contains("Turn started")
+ && snapshot.isShowingEmptyState == false
+ }
+
+ #expect(updatedSnapshot.log.contains("Turn started"))
+ }
+
@Test func windowControllerUsesSeededAuthenticatedStateOnFirstPresentation() {
let backend = AuthActionBackend(
initialAuthState: .signedIn(accountID: "review@example.com")
@@ -712,7 +1383,7 @@ extension ReviewUITests {
let windowController = ReviewMonitorWindowController(
store: store,
contentTransitionAnimator: ReviewMonitorRootViewController.defaultContentTransitionAnimator,
- sidebarJobFilterDefaults: nil
+ sidebarReviewChatFilterDefaults: nil
)
guard let window = windowController.window else {
Issue.record("ReviewMonitorWindowController did not create a window.")
@@ -737,7 +1408,7 @@ extension ReviewUITests {
store: store,
contentTransitionAnimator: ReviewMonitorRootViewController.defaultContentTransitionAnimator,
frameAutosaveName: autosaveName,
- sidebarJobFilterDefaults: nil
+ sidebarReviewChatFilterDefaults: nil
)
guard let window = windowController.window else {
Issue.record("ReviewMonitorWindowController did not create a window.")
@@ -755,18 +1426,17 @@ extension ReviewUITests {
@Test func windowControllerKeepsSplitViewForUnsavedCurrentSession() {
let store = CodexReviewStore.makePreviewStore()
- let currentAccount = CodexAccount(email: "current@example.com", planType: "pro")
+ let currentAccount = CodexReviewAccount(email: "current@example.com", planType: "pro")
store.loadForTesting(
serverState: .running,
authPhase: .signedOut,
account: currentAccount,
- persistedAccounts: [],
- workspaces: []
+ persistedAccounts: []
)
let windowController = ReviewMonitorWindowController(
store: store,
contentTransitionAnimator: ReviewMonitorRootViewController.defaultContentTransitionAnimator,
- sidebarJobFilterDefaults: nil
+ sidebarReviewChatFilterDefaults: nil
)
guard let window = windowController.window else {
Issue.record("ReviewMonitorWindowController did not create a window.")
@@ -784,17 +1454,16 @@ extension ReviewUITests {
@Test func accountSidebarDisplaysUnsavedCurrentSession() {
let store = CodexReviewStore.makePreviewStore()
- let currentAccount = CodexAccount(email: "current@example.com", planType: "pro")
+ let currentAccount = CodexReviewAccount(email: "current@example.com", planType: "pro")
store.loadForTesting(
serverState: .running,
authPhase: .signedOut,
account: currentAccount,
- persistedAccounts: [],
- workspaces: []
+ persistedAccounts: []
)
let uiState = ReviewMonitorUIState(auth: store.auth)
uiState.sidebarSelection = .account
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(store: store, uiState: uiState)
viewController.loadViewIfNeeded()
@@ -829,13 +1498,12 @@ extension ReviewUITests {
serverState: .running,
authPhase: .signedOut,
account: nil,
- persistedAccounts: [CodexAccount(email: "saved@example.com", planType: "pro")],
- workspaces: []
+ persistedAccounts: [CodexReviewAccount(email: "saved@example.com", planType: "pro")]
)
let windowController = ReviewMonitorWindowController(
store: store,
contentTransitionAnimator: ReviewMonitorRootViewController.defaultContentTransitionAnimator,
- sidebarJobFilterDefaults: nil
+ sidebarReviewChatFilterDefaults: nil
)
guard let window = windowController.window else {
Issue.record("ReviewMonitorWindowController did not create a window.")
@@ -861,7 +1529,7 @@ extension ReviewUITests {
let windowController = ReviewMonitorWindowController(
store: store,
contentTransitionAnimator: ReviewMonitorRootViewController.defaultContentTransitionAnimator,
- sidebarJobFilterDefaults: nil
+ sidebarReviewChatFilterDefaults: nil
)
defer { windowController.window?.close() }
await Task.yield()
@@ -1035,12 +1703,14 @@ extension ReviewUITests {
)
defer { harness.window.close() }
- applyTestAuthState(auth: store.auth, state:
- .failed(
- "Authentication failed.",
- isAuthenticated: true,
- accountID: "review@example.com"
- )
+ applyTestAuthState(
+ auth: store.auth,
+ state:
+ .failed(
+ "Authentication failed.",
+ isAuthenticated: true,
+ accountID: "review@example.com"
+ )
)
await Task.yield()
@@ -1065,14 +1735,13 @@ extension ReviewUITests {
}
@Test func detailLogViewExtendsBehindTitlebarWithoutOverlappingSidebar() async throws {
- let job = makeJob(
- id: "job-safe-area",
- status: .running,
- targetSummary: "Uncommitted changes",
- logText: "Safe area log\n"
+ let logText = "Safe area log\n"
+ let chat = makeShellReviewChatForTesting(
+ id: "chat-safe-area",
+ title: "Uncommitted changes"
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
let harness = makeWindowHarness(
store: store,
contentSize: NSSize(width: 900, height: 600)
@@ -1081,9 +1750,7 @@ extension ReviewUITests {
let window = harness.window
defer { window.close() }
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
- transport.view.layoutSubtreeIfNeeded()
+ try await renderDetailLogForShellLayoutTesting(logText, in: transport, viewController: viewController, chat: chat)
let logFrame = transport.logFrameForTesting
let viewBounds = transport.viewBoundsForTesting
@@ -1098,21 +1765,21 @@ extension ReviewUITests {
#expect(transport.logAutomaticallyAdjustsContentInsetsForTesting)
#expect(contentInsets.top > 0)
#expect(abs(transport.logVerticalScrollOffsetForTesting + contentInsets.top) < 0.5)
- #expect(abs(
- transport.logMaximumVerticalScrollOffsetForTesting
- - transport.logMinimumVerticalScrollOffsetForTesting
- ) < 0.5)
+ #expect(
+ abs(
+ transport.logMaximumVerticalScrollOffsetForTesting
+ - transport.logMinimumVerticalScrollOffsetForTesting
+ ) < 0.5)
}
@Test func shortDetailLogKeepsTextContentWithinDocumentBounds() async throws {
- let job = makeJob(
- id: "job-short-log-layout",
- status: .running,
- targetSummary: "Uncommitted changes",
- logText: "Short log\n"
+ let logText = "Short log\n"
+ let chat = makeShellReviewChatForTesting(
+ id: "chat-short-log-layout",
+ title: "Uncommitted changes"
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
let harness = makeWindowHarness(
store: store,
contentSize: NSSize(width: 900, height: 600)
@@ -1121,9 +1788,7 @@ extension ReviewUITests {
let window = harness.window
defer { window.close() }
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
- transport.view.layoutSubtreeIfNeeded()
+ try await renderDetailLogForShellLayoutTesting(logText, in: transport, viewController: viewController, chat: chat)
let textContentFrame = transport.logTextContentFrameForTesting
let documentViewFrame = transport.logDocumentViewFrameForTesting
@@ -1135,14 +1800,14 @@ extension ReviewUITests {
}
@Test func detailLogExpandsAfterSidebarReopensFromCompactWidth() async throws {
- let job = makeJob(
- id: "job-sidebar-width-regression",
- status: .running,
- targetSummary: "Uncommitted changes",
- logText: Array(repeating: "Long line that should reflow across the widened detail pane.\n", count: 40).joined()
+ let logText = Array(repeating: "Long line that should reflow across the widened detail pane.\n", count: 40)
+ .joined()
+ let chat = makeShellReviewChatForTesting(
+ id: "chat-sidebar-width-regression",
+ title: "Uncommitted changes"
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
let harness = makeWindowHarness(
store: store,
contentSize: NSSize(width: 520, height: 420)
@@ -1153,8 +1818,7 @@ extension ReviewUITests {
try await waitForWindowContentKind(harness.rootViewController, .contentView)
let transport = viewController.transportViewControllerForTesting
let sidebarItem = try #require(viewController.splitViewItems.first)
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ try await renderDetailLogForShellLayoutTesting(logText, in: transport, viewController: viewController, chat: chat)
window.setContentSize(NSSize(width: 360, height: 420))
sidebarItem.isCollapsed = true
@@ -1181,14 +1845,14 @@ extension ReviewUITests {
}
@Test func detailLogShrinksAfterSidebarReopensIntoNarrowWidth() async throws {
- let job = makeJob(
- id: "job-sidebar-width-shrink-regression",
- status: .running,
- targetSummary: "Uncommitted changes",
- logText: Array(repeating: "Long line that should reflow when the detail pane narrows.\n", count: 40).joined()
+ let logText = Array(repeating: "Long line that should reflow when the detail pane narrows.\n", count: 40)
+ .joined()
+ let chat = makeShellReviewChatForTesting(
+ id: "chat-sidebar-width-shrink-regression",
+ title: "Uncommitted changes"
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
let harness = makeWindowHarness(
store: store,
contentSize: NSSize(width: 960, height: 600)
@@ -1199,10 +1863,7 @@ extension ReviewUITests {
try await waitForWindowContentKind(harness.rootViewController, .contentView)
let transport = viewController.transportViewControllerForTesting
let sidebarItem = try #require(viewController.splitViewItems.first)
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
- window.layoutIfNeeded()
- transport.view.layoutSubtreeIfNeeded()
+ try await renderDetailLogForShellLayoutTesting(logText, in: transport, viewController: viewController, chat: chat)
let expandedDocumentWidth = transport.logDocumentViewFrameForTesting.width
expectLogTextContainerWidthTracksContentView(transport)
@@ -1223,14 +1884,13 @@ extension ReviewUITests {
}
@Test func detailLogTracksSimpleWindowResizeInBothDirections() async throws {
- let job = makeJob(
- id: "job-window-resize-width-regression",
- status: .running,
- targetSummary: "Uncommitted changes",
- logText: Array(repeating: "Long line that should reflow as the window resizes.\n", count: 40).joined()
+ let logText = Array(repeating: "Long line that should reflow as the window resizes.\n", count: 40).joined()
+ let chat = makeShellReviewChatForTesting(
+ id: "chat-window-resize-width-regression",
+ title: "Uncommitted changes"
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
let harness = makeWindowHarness(
store: store,
contentSize: NSSize(width: 960, height: 600)
@@ -1239,10 +1899,7 @@ extension ReviewUITests {
let window = harness.window
defer { window.close() }
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
- window.layoutIfNeeded()
- transport.view.layoutSubtreeIfNeeded()
+ try await renderDetailLogForShellLayoutTesting(logText, in: transport, viewController: viewController, chat: chat)
let wideWidth = transport.logDocumentViewFrameForTesting.width
expectLogTextContainerWidthTracksContentView(transport)
@@ -1273,15 +1930,13 @@ extension ReviewUITests {
}
@Test func detailLogRewrapsVisibleTextDuringLiveWindowResize() async throws {
- let job = makeJob(
- id: "job-window-live-resize-log",
- status: .running,
- targetSummary: "Uncommitted changes",
- summary: "Running review.",
- logText: String(repeating: "wrap-sensitive text ", count: 600)
+ let logText = String(repeating: "wrap-sensitive text ", count: 600)
+ let chat = makeShellReviewChatForTesting(
+ id: "chat-window-live-resize-log",
+ title: "Uncommitted changes"
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
let harness = makeWindowHarness(
store: store,
contentSize: NSSize(width: 960, height: 600)
@@ -1296,10 +1951,7 @@ extension ReviewUITests {
}
window.close()
}
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
- window.layoutIfNeeded()
- transport.view.layoutSubtreeIfNeeded()
+ try await renderDetailLogForShellLayoutTesting(logText, in: transport, viewController: viewController, chat: chat)
let wideWidth = transport.logDocumentViewFrameForTesting.width
let wideDocumentHeight = transport.logDocumentViewFrameForTesting.height
let wideFragmentHeight = transport.logVisibleFragmentBoundsForTesting.height
@@ -1333,15 +1985,12 @@ extension ReviewUITests {
"stream.tick \(String(format: "%03d", index)) delta/render +5 -3 after resizing the split view, avoiding sidebar auto-collapse, and keeping visible TextKit 2 fragments fresh"
}
.joined(separator: "\n\n")
- let job = makeJob(
- id: "job-window-live-resize-stream-log",
- status: .running,
- targetSummary: "Uncommitted changes",
- summary: "Running review.",
- logText: streamLog
+ let chat = makeShellReviewChatForTesting(
+ id: "chat-window-live-resize-stream-log",
+ title: "Uncommitted changes"
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
let harness = makeWindowHarness(
store: store,
contentSize: NSSize(width: 1_060, height: 600)
@@ -1356,10 +2005,8 @@ extension ReviewUITests {
}
window.close()
}
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
- window.layoutIfNeeded()
- transport.view.layoutSubtreeIfNeeded()
+ try await renderDetailLogForShellLayoutTesting(
+ streamLog, in: transport, viewController: viewController, chat: chat)
#expect(transport.isLogPinnedToBottomForTesting)
transport.beginLogLiveResizeForTesting()
@@ -1379,14 +2026,15 @@ extension ReviewUITests {
}
@Test func detailLogTextContainerExpandsAfterToolbarSidebarToggleAtCompactWidth() async throws {
- let job = makeJob(
- id: "job-toolbar-sidebar-toggle-textkit-width-regression",
- status: .running,
- targetSummary: "Uncommitted changes",
- logText: Array(repeating: "Long line that should reflow after the toolbar sidebar toggle path.\n", count: 40).joined()
+ let logText = Array(
+ repeating: "Long line that should reflow after the toolbar sidebar toggle path.\n", count: 40
+ ).joined()
+ let chat = makeShellReviewChatForTesting(
+ id: "chat-toolbar-sidebar-toggle-textkit-width-regression",
+ title: "Uncommitted changes"
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
let harness = makeWindowHarness(
store: store,
contentSize: NSSize(width: 520, height: 420)
@@ -1401,8 +2049,7 @@ extension ReviewUITests {
window.layoutIfNeeded()
viewController.view.layoutSubtreeIfNeeded()
transport.view.layoutSubtreeIfNeeded()
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ try await renderDetailLogForShellLayoutTesting(logText, in: transport, viewController: viewController, chat: chat)
viewController.toggleSidebar(nil)
await awaitNativeLayoutTurn()
@@ -1451,7 +2098,8 @@ extension ReviewUITests {
@Test func splitViewAttachIsIdempotentForSameWindow() {
let backend = CountingStartBackend()
let store = makeStore(backend: backend)
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
@@ -1465,157 +2113,217 @@ extension ReviewUITests {
#expect(backend.startCallCount() == 0)
}
- @Test func previewRunningJobsAppendPseudoStreamWhenTicked() throws {
- let store = ReviewMonitorPreviewContent.makeStore(
- streamInterval: nil
- )
- let runningJob = try #require(
- store.orderedJobs.first(where: { $0.core.lifecycle.status == .running })
- )
- let initialLog = reviewMonitorLogText(for: runningJob)
- let initialEntryCount = runningJob.logEntries.count
+ @Test func previewRunningChatsAppendPseudoStreamWhenTicked() async throws {
+ let source = ReviewMonitorPreviewContent.makeContentSource()
+ let runningChatID = CodexThreadID(rawValue: "preview-thread-0-0")
+ let initialSnapshot = try #require(await source.snapshotForTesting(chatID: runningChatID))
+ let initialItemCount = initialSnapshot.items.count
- ReviewMonitorPreviewContent.appendPreviewStreamTick(to: store)
+ await source.appendPreviewChatLogStreamTick()
- let appendedText = String(reviewMonitorLogText(for: runningJob).dropFirst(initialLog.count))
- let appendedEntries = Array(runningJob.logEntries.dropFirst(initialEntryCount))
- #expect(reviewMonitorLogText(for: runningJob) != initialLog)
+ let updatedSnapshot = try #require(await source.snapshotForTesting(chatID: runningChatID))
+ let appendedItems = Array(updatedSnapshot.items.dropFirst(initialItemCount))
+ let appendedText = appendedItems.compactMap { $0.text }.joined(separator: "\n")
+ #expect(updatedSnapshot.items != initialSnapshot.items)
#expect(appendedText.contains("Turn started"))
#expect(appendedText.contains("delta/") == false)
#expect(appendedText.count < 160)
- #expect(appendedEntries.count == 1)
- #expect(appendedEntries.first?.kind == .event)
- #expect(appendedEntries.first?.text.contains("preview-turn") == true)
+ #expect(appendedItems.count == 1)
+ #expect(appendedItems.first?.kind.rawValue == "event")
+ #expect(appendedItems.first.map(diagnosticMessage)?.contains("preview-turn") == true)
}
- @Test func previewStreamUsesMixedReviewLogKinds() throws {
- let store = ReviewMonitorPreviewContent.makeStore(
- streamInterval: nil
- )
- let runningJob = try #require(
- store.orderedJobs.first(where: { $0.core.lifecycle.status == .running })
- )
- let initialEntryCount = runningJob.logEntries.count
+ @Test func previewChatStreamUsesMixedLogKinds() async throws {
+ let source = ReviewMonitorPreviewContent.makeContentSource()
+ let runningChatID = CodexThreadID(rawValue: "preview-thread-0-0")
+ let initialSnapshot = try #require(await source.snapshotForTesting(chatID: runningChatID))
+ let initialItemCount = initialSnapshot.items.count
var tick = 0
for _ in 0..<720 {
- tick = ReviewMonitorPreviewContent.appendPreviewStreamTick(to: store, after: tick)
- }
-
- let appendedEntries = Array(runningJob.logEntries.dropFirst(initialEntryCount))
- let appendedKinds = appendedEntries.map(\.kind)
- #expect(appendedKinds.contains(.event))
- #expect(appendedKinds.contains(.command))
- #expect(appendedKinds.contains(.toolCall))
- #expect(appendedKinds.contains(.plan))
- #expect(appendedKinds.contains(.contextCompaction))
- #expect(appendedKinds.contains(.reasoningSummary))
- #expect(appendedKinds.contains(.agentMessage))
- #expect(Set(appendedKinds).count >= 6)
-
- let repeatedNonReplacingGroups = Dictionary(
- grouping: appendedEntries.filter { $0.replacesGroup == false },
- by: { entry in "\(entry.groupID ?? "")|\(entry.kind.rawValue)" }
- ).values.filter { entries in
- entries.first?.groupID != nil && entries.count > 1
- }
- for entries in repeatedNonReplacingGroups {
- let kind = try #require(entries.first?.kind)
- #expect([.reasoning, .reasoningSummary, .rawReasoning].contains(kind))
+ tick = await source.appendPreviewChatLogStreamTick(after: tick)
}
- let compactionEntries = runningJob.logEntries
- .dropFirst(initialEntryCount)
- .filter { $0.kind == .contextCompaction }
- #expect(compactionEntries.count >= 2)
- #expect(compactionEntries.last?.text == "Context automatically compacted")
- #expect(compactionEntries.last?.metadata?.status == "completed")
- let renderedLog = reviewMonitorLogText(for: runningJob)
+ let updatedSnapshot = try #require(await source.snapshotForTesting(chatID: runningChatID))
+ let appendedItems = Array(updatedSnapshot.items.dropFirst(initialItemCount))
+ let appendedKinds = appendedItems.map { $0.kind.rawValue }
+ #expect(appendedKinds.contains("event"))
+ #expect(appendedKinds.contains("commandExecution"))
+ #expect(appendedKinds.contains("mcpToolCall"))
+ #expect(appendedKinds.contains("plan"))
+ #expect(appendedKinds.contains("contextCompaction"))
+ #expect(appendedKinds.contains("reasoning"))
+ #expect(appendedKinds.contains("agentMessage"))
+ #expect(Set(appendedKinds).count >= 6)
+ #expect(Set(appendedItems.map { $0.id }).count == appendedItems.count)
+
+ let compactionItems = updatedSnapshot.items
+ .dropFirst(initialItemCount)
+ .filter { $0.kind.rawValue == "contextCompaction" }
+ let compactionItem = try #require(compactionItems.last)
+ #expect(contextCompactionTitle(compactionItem) == "Context automatically compacted")
+ let renderedLog = updatedSnapshot.items.compactMap { $0.text }.joined(separator: "\n")
#expect(renderedLog.contains("Context automatically compacted"))
#expect(renderedLog.contains("Automatically compacting context") == false)
}
- @Test func previewStreamWaitsAfterEachCompletedItemAndDrainsChunks() throws {
- let store = ReviewMonitorPreviewContent.makeStore(
- streamInterval: nil
- )
- let runningJob = try #require(
- store.orderedJobs.first(where: { $0.core.lifecycle.status == .running })
- )
- let initialEntryCount = runningJob.logEntries.count
+ @Test func previewChatStreamWaitsAfterEachCompletedItemAndDrainsChunks() async throws {
+ let source = ReviewMonitorPreviewContent.makeContentSource()
+ let runningChatID = CodexThreadID(rawValue: "preview-thread-0-0")
+ let initialSnapshot = try #require(await source.snapshotForTesting(chatID: runningChatID))
+ let initialItemCount = initialSnapshot.items.count
var tick = 0
- tick = ReviewMonitorPreviewContent.appendPreviewStreamTick(to: store, after: tick)
- #expect(runningJob.logEntries.count == initialEntryCount + 1)
- #expect(runningJob.logEntries.last?.kind == .event)
+ tick = await source.appendPreviewChatLogStreamTick(after: tick)
+ var snapshot = try #require(await source.snapshotForTesting(chatID: runningChatID))
+ #expect(snapshot.items.count == initialItemCount + 1)
+ #expect(snapshot.items.last?.kind.rawValue == "event")
for _ in 0..<38 {
- tick = ReviewMonitorPreviewContent.appendPreviewStreamTick(to: store, after: tick)
+ tick = await source.appendPreviewChatLogStreamTick(after: tick)
}
- #expect(runningJob.logEntries.count == initialEntryCount + 1)
+ snapshot = try #require(await source.snapshotForTesting(chatID: runningChatID))
+ #expect(snapshot.items.count == initialItemCount + 1)
- tick = ReviewMonitorPreviewContent.appendPreviewStreamTick(to: store, after: tick)
- #expect(runningJob.logEntries.count == initialEntryCount + 2)
- #expect(runningJob.logEntries.last?.kind == .plan)
+ tick = await source.appendPreviewChatLogStreamTick(after: tick)
+ snapshot = try #require(await source.snapshotForTesting(chatID: runningChatID))
+ #expect(snapshot.items.count == initialItemCount + 2)
+ #expect(snapshot.items.last?.kind.rawValue == "plan")
- var observedEntryCount = runningJob.logEntries.count
- for _ in 0..<180 where runningJob.logEntries.last?.kind != .reasoningSummary {
- tick = ReviewMonitorPreviewContent.appendPreviewStreamTick(to: store, after: tick)
- observedEntryCount = runningJob.logEntries.count
+ for _ in 0..<180 where snapshot.items.last?.kind.rawValue != "reasoning" {
+ tick = await source.appendPreviewChatLogStreamTick(after: tick)
+ snapshot = try #require(await source.snapshotForTesting(chatID: runningChatID))
}
- let firstReasoning = try #require(runningJob.logEntries.last)
- #expect(firstReasoning.kind == .reasoningSummary)
- let reasoningGroupID = firstReasoning.groupID
- var reasoningChunkCount = 1
+ let firstReasoning = try #require(snapshot.items.last)
+ #expect(firstReasoning.kind.rawValue == "reasoning")
+ let reasoningID = firstReasoning.id
+ let initialReasoningText = reasoningText(firstReasoning)
+ var latestReasoningText = initialReasoningText
for _ in 0..<80 {
- tick = ReviewMonitorPreviewContent.appendPreviewStreamTick(to: store, after: tick)
- let entries = Array(runningJob.logEntries.dropFirst(observedEntryCount))
- if entries.isEmpty {
+ tick = await source.appendPreviewChatLogStreamTick(after: tick)
+ snapshot = try #require(await source.snapshotForTesting(chatID: runningChatID))
+ let item = try #require(snapshot.items.first { $0.id == reasoningID })
+ let text = reasoningText(item)
+ if text == latestReasoningText {
break
}
- #expect(entries.count == 1)
- let entry = try #require(entries.first)
- #expect(entry.kind == .reasoningSummary)
- #expect(entry.groupID == reasoningGroupID)
- reasoningChunkCount += 1
- observedEntryCount = runningJob.logEntries.count
+ latestReasoningText = text
}
- #expect(reasoningChunkCount > 1)
- let countAfterReasoning = runningJob.logEntries.count
+ #expect(latestReasoningText.count > initialReasoningText.count)
+ let countAfterReasoning = snapshot.items.count
for _ in 0..<37 {
- tick = ReviewMonitorPreviewContent.appendPreviewStreamTick(to: store, after: tick)
+ tick = await source.appendPreviewChatLogStreamTick(after: tick)
}
- #expect(runningJob.logEntries.count == countAfterReasoning)
+ snapshot = try #require(await source.snapshotForTesting(chatID: runningChatID))
+ #expect(snapshot.items.count == countAfterReasoning)
- tick = ReviewMonitorPreviewContent.appendPreviewStreamTick(to: store, after: tick)
- #expect(runningJob.logEntries.count == countAfterReasoning + 1)
- #expect(runningJob.logEntries.last?.kind == .command)
+ tick = await source.appendPreviewChatLogStreamTick(after: tick)
+ snapshot = try #require(await source.snapshotForTesting(chatID: runningChatID))
+ #expect(snapshot.items.count == countAfterReasoning + 1)
+ #expect(snapshot.items.last?.kind.rawValue == "commandExecution")
}
- @Test func previewFirstWorkspaceShowsStructuredFindingsWhenSelected() async throws {
- let store = ReviewMonitorPreviewContent.makeStore(
- streamInterval: nil
- )
- let firstWorkspace = try #require(store.workspaces.sorted { $0.cwd < $1.cwd }.first)
- let harness = makeWindowHarness(store: store)
- let viewController = harness.viewController
- let window = harness.window
- defer { window.close() }
- let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectWorkspaceForTesting(firstWorkspace)
+}
+
+@MainActor
+private func previewSelectedChatID(in store: CodexReviewStore) -> CodexThreadID? {
+ _ = store
+ return CodexThreadID(rawValue: "preview-thread-0-0")
+}
+
+private func makeShellTestGitRepository() throws -> URL {
+ let repo = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: repo, withIntermediateDirectories: true)
+ try FileManager.default.createDirectory(
+ at: repo.appendingPathComponent(".git", isDirectory: true),
+ withIntermediateDirectories: true
+ )
+ return repo
+}
+
+@MainActor
+private func performChatContextMenuItemForTesting(
+ title: String,
+ chatID: CodexThreadID,
+ sidebar: ReviewMonitorSidebarViewController
+) -> (presented: Bool, enabled: Bool) {
+ var presented = false
+ var enabled = false
+ sidebar.presentContextMenuForTesting(chatID: chatID) { menu in
+ guard let itemIndex = menu.items.firstIndex(where: { $0.title == title }) else {
+ return
+ }
+ presented = true
+ enabled = menu.items[itemIndex].isEnabled
+ menu.performActionForItem(at: itemIndex)
+ }
+ return (presented: presented, enabled: enabled)
+}
+
+@MainActor
+private func diagnosticMessage(_ item: CodexThreadItem) -> String {
+ if case .diagnostic(let message) = item.content {
+ return message
+ }
+ return ""
+}
- _ = try await awaitTransportRender(transport)
- let accessibilityValue = try #require(transport.workspaceFindingsAccessibilityValueForTesting)
- #expect(transport.workspaceFindingSnapshotForTesting.isShowingFindingsList)
- #expect(transport.workspaceFindingSnapshotForTesting.isShowingNoFindingsState == false)
- #expect(accessibilityValue.isEmpty == false)
+@MainActor
+private func reasoningText(_ item: CodexThreadItem) -> String {
+ if case .reasoning(let reasoning) = item.content {
+ return reasoning.text
+ }
+ return ""
+}
+
+@MainActor
+private func contextCompactionTitle(_ item: CodexThreadItem) -> String {
+ if case .contextCompaction(let title) = item.content {
+ return title ?? ""
+ }
+ return ""
+}
+
+@MainActor
+private func makeShellReviewChatForTesting(
+ id: String,
+ title: String
+) -> ReviewChatFixtureForTesting {
+ makeReviewChatFixtureForTesting(
+ id: id,
+ title: title,
+ status: .running,
+ startedAt: Date(timeIntervalSince1970: 200)
+ )
+}
+
+@MainActor
+private func renderDetailLogForShellLayoutTesting(
+ _ text: String,
+ in transport: ReviewMonitorTransportViewController,
+ viewController: ReviewMonitorSplitViewController,
+ chat: ReviewChatFixtureForTesting
+) async throws {
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ let expectedSelection: ReviewMonitorTransportViewController.DisplayedSelectionForTesting = .chat(chat.chatID.rawValue)
+ try await waitForCondition {
+ transport.renderedStateForTesting.selection == expectedSelection
+ && transport.renderedStateForTesting.snapshot.isShowingEmptyState == false
+ }
+ #expect(transport.renderLogForTesting(text: text, allowIncrementalUpdate: false))
+ transport.scrollLogToBottomForTesting()
+ if let window = viewController.view.window {
+ window.layoutIfNeeded()
}
+ viewController.view.layoutSubtreeIfNeeded()
+ transport.view.layoutSubtreeIfNeeded()
}
-private func makeSidebarJobFilterDefaultsForTesting() throws -> (defaults: UserDefaults, suiteName: String) {
- let suiteName = "ReviewMonitorSidebarJobFilterDefaultsTests-\(UUID().uuidString)"
+private func makeSidebarReviewChatFilterDefaultsForTesting() throws -> (defaults: UserDefaults, suiteName: String) {
+ let suiteName = "ReviewMonitorSidebarReviewChatFilterDefaultsTests-\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defaults.removePersistentDomain(forName: suiteName)
return (defaults, suiteName)
diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift
index d5979906..1f02d39e 100644
--- a/Tests/ReviewUITests/ReviewUITests.swift
+++ b/Tests/ReviewUITests/ReviewUITests.swift
@@ -1,21 +1,24 @@
import AppKit
+import CodexAppServerKitTesting
import Foundation
import ObservationBridge
-import CodexReviewDomain
-import ReviewMonitorRendering
+import CodexKit
+import CodexReviewKit
import SwiftUI
import Testing
-@_spi(Testing) @testable import CodexReview
-@_spi(PreviewSupport) @testable import ReviewUI
+@_spi(Testing) @testable import CodexReviewKit
+@testable import ReviewChatLogUI
+@testable import ReviewUI
+@testable import ReviewUIPreviewSupport
import CodexReviewTesting
@MainActor
private extension CodexReviewAuthModel {
- func updatePersistedAccounts(_ accounts: [CodexAccount]) {
+ func updatePersistedAccounts(_ accounts: [CodexReviewAccount]) {
applyPersistedAccountStates(accounts.map(savedAccountPayload(from:)))
}
- func updateAccount(_ account: CodexAccount?) {
+ func updateAccount(_ account: CodexReviewAccount?) {
updateCurrentAccount(account)
}
}
@@ -24,2194 +27,363 @@ private extension CodexReviewAuthModel {
@MainActor
struct ReviewUITests {
- @Test func splitViewSectionsByWorkspace() {
- let workspaceAlphaJob = makeJob(
- cwd: "/tmp/workspace-alpha",
- startedAt: Date(timeIntervalSince1970: 200),
- status: .running,
- targetSummary: "Uncommitted changes"
- )
- let workspaceBetaJob = makeJob(
- cwd: "/tmp/workspace-beta",
- startedAt: Date(timeIntervalSince1970: 100),
- status: .failed,
- targetSummary: "Base branch: main"
- )
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- content: makeSidebarContent(from: [workspaceBetaJob, workspaceAlphaJob])
- )
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
- viewController.loadViewIfNeeded()
-
- #expect(viewController.sidebarViewControllerForTesting.displayedSectionTitlesForTesting == [
- "workspace-alpha",
- "workspace-beta",
- ])
- #expect(viewController.splitViewItems.count == 2)
- #expect(viewController.splitViewItems[0].behavior == .sidebar)
- #expect(viewController.splitViewItems[1].behavior == .default)
- #expect(viewController.sidebarAccessoryCountForTesting == 1)
- #expect(viewController.contentAccessoryCountForTesting == 0)
- }
-
- @Test func workspaceDropReordersDisplayedSectionsImmediately() async {
- let workspaceAlphaJob = makeJob(
- id: "job-workspace-alpha",
- cwd: "/tmp/workspace-alpha",
- status: .running,
- targetSummary: "Uncommitted changes"
- )
- let workspaceBetaJob = makeJob(
- id: "job-workspace-beta",
- cwd: "/tmp/workspace-beta",
- status: .failed,
- targetSummary: "Base branch: main"
- )
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- content: makeSidebarContent(from: [workspaceBetaJob, workspaceAlphaJob])
- )
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
- viewController.loadViewIfNeeded()
-
- let sidebar = viewController.sidebarViewControllerForTesting
- guard let workspaceAlpha = store.workspaces.first(where: { $0.cwd == "/tmp/workspace-alpha" }) else {
- Issue.record("workspace-alpha was not loaded.")
- return
- }
- let fullReloadCountBeforeDrop = sidebar.sidebarFullReloadCountForTesting
- let workspaceReloadCountBeforeDrop = sidebar.sidebarWorkspaceReloadCountForTesting
- let incrementalMoveCountBeforeDrop = sidebar.sidebarIncrementalMoveCountForTesting
- #expect(sidebar.performWorkspaceDropForTesting(workspaceAlpha, toIndex: store.workspaces.count))
- await Task.yield()
-
- #expect(sidebar.displayedSectionTitlesForTesting == [
- "workspace-beta",
- "workspace-alpha",
- ])
- #expect(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeDrop)
- #expect(sidebar.sidebarWorkspaceReloadCountForTesting == workspaceReloadCountBeforeDrop)
- #expect(sidebar.sidebarIncrementalMoveCountForTesting == incrementalMoveCountBeforeDrop + 1)
- }
-
- @Test func workspaceDropOnWorkspaceRowReordersDisplayedSections() {
- let workspaceAlphaJob = makeJob(
- id: "job-workspace-alpha-on-row",
- cwd: "/tmp/workspace-alpha",
- status: .running,
- targetSummary: "Uncommitted changes"
- )
- let workspaceBetaJob = makeJob(
- id: "job-workspace-beta-on-row",
- cwd: "/tmp/workspace-beta",
- status: .failed,
- targetSummary: "Base branch: main"
- )
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- content: makeSidebarContent(from: [workspaceBetaJob, workspaceAlphaJob])
- )
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
- viewController.loadViewIfNeeded()
-
- let sidebar = viewController.sidebarViewControllerForTesting
- guard let workspaceAlpha = store.workspaces.first(where: { $0.cwd == "/tmp/workspace-alpha" }),
- let workspaceBeta = store.workspaces.first(where: { $0.cwd == "/tmp/workspace-beta" })
- else {
- Issue.record("workspaces were not loaded.")
- return
- }
-
- #expect(sidebar.performWorkspaceDropForTesting(workspaceBeta, proposedWorkspace: workspaceAlpha))
- #expect(sidebar.displayedSectionTitlesForTesting == [
- "workspace-beta",
- "workspace-alpha",
- ])
- }
-
- @Test func workspaceMembershipChangeUsesRootInsertWithoutFullReload() async throws {
- let alphaJob = makeJob(
- id: "job-workspace-alpha-membership",
- cwd: "/tmp/workspace-alpha",
- status: .running,
- targetSummary: "Uncommitted changes"
- )
- let betaJob = makeJob(
- id: "job-workspace-beta-membership",
- cwd: "/tmp/workspace-beta",
- status: .succeeded,
- targetSummary: "Commit: abc123"
- )
- let alphaWorkspace = CodexReviewWorkspace(cwd: alphaJob.cwd)
- let betaWorkspace = CodexReviewWorkspace(cwd: betaJob.cwd)
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- workspaces: [alphaWorkspace],
- jobs: [alphaJob]
- )
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
- viewController.loadViewIfNeeded()
-
- let sidebar = viewController.sidebarViewControllerForTesting
- let fullReloadCountBeforeMembershipChange = sidebar.sidebarFullReloadCountForTesting
- let incrementalMoveCountBeforeMembershipChange = sidebar.sidebarIncrementalMoveCountForTesting
- let incrementalMembershipChangeCountBeforeMembershipChange = sidebar.sidebarIncrementalMembershipChangeCountForTesting
-
- store.loadForTesting(
- serverState: .running,
- workspaces: [alphaWorkspace, betaWorkspace],
- jobs: [alphaJob, betaJob]
- )
- try await waitForObservedValue(
- from: sidebar.sidebarTopologyObservationForTesting,
- [
- "workspace-alpha",
- "workspace-beta",
- ]
- ) {
- sidebar.displayedSectionTitlesForTesting
- }
-
- #expect(sidebar.displayedSectionTitlesForTesting == [
- "workspace-alpha",
- "workspace-beta",
- ])
- #expect(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeMembershipChange)
- #expect(sidebar.sidebarIncrementalMoveCountForTesting == incrementalMoveCountBeforeMembershipChange)
- #expect(sidebar.sidebarIncrementalMembershipChangeCountForTesting == incrementalMembershipChangeCountBeforeMembershipChange + 1)
- }
-
- @Test func workspaceSameMembershipSortOrderChangeMovesRowsWithoutReload() async throws {
- let alphaJob = makeJob(
- id: "job-workspace-alpha-sort-order",
- cwd: "/tmp/workspace-alpha",
- status: .running,
- targetSummary: "Uncommitted changes"
- )
- let betaJob = makeJob(
- id: "job-workspace-beta-sort-order",
- cwd: "/tmp/workspace-beta",
- status: .succeeded,
- targetSummary: "Commit: abc123"
- )
- let alphaWorkspace = CodexReviewWorkspace(cwd: alphaJob.cwd)
- let betaWorkspace = CodexReviewWorkspace(cwd: betaJob.cwd)
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- workspaces: [alphaWorkspace, betaWorkspace],
- jobs: [alphaJob, betaJob]
- )
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
- viewController.loadViewIfNeeded()
-
- let sidebar = viewController.sidebarViewControllerForTesting
- let fullReloadCountBeforeChange = sidebar.sidebarFullReloadCountForTesting
- let incrementalMoveCountBeforeChange = sidebar.sidebarIncrementalMoveCountForTesting
- let incrementalMembershipChangeCountBeforeChange = sidebar.sidebarIncrementalMembershipChangeCountForTesting
-
- store.loadForTesting(
- serverState: .running,
- workspaces: [betaWorkspace, alphaWorkspace],
- jobs: [alphaJob, betaJob]
- )
- try await waitForObservedValue(
- from: sidebar.sidebarTopologyObservationForTesting,
- [
- "workspace-beta",
- "workspace-alpha",
- ]
- ) {
- sidebar.displayedSectionTitlesForTesting
- }
-
- #expect(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeChange)
- #expect(sidebar.sidebarIncrementalMembershipChangeCountForTesting == incrementalMembershipChangeCountBeforeChange)
- #expect(sidebar.sidebarIncrementalMoveCountForTesting == incrementalMoveCountBeforeChange + 1)
- }
-
- @Test func workspaceInsertionIndexFollowsCurrentHoverPosition() {
- let workspaceAlphaJob = makeJob(
- id: "job-workspace-alpha-blank",
- cwd: "/tmp/workspace-alpha",
- status: .running,
- targetSummary: "Uncommitted changes"
- )
- let workspaceBetaJob = makeJob(
- id: "job-workspace-beta-blank",
- cwd: "/tmp/workspace-beta",
- status: .failed,
- targetSummary: "Base branch: main"
- )
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- content: makeSidebarContent(from: [workspaceBetaJob, workspaceAlphaJob])
- )
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
- viewController.loadViewIfNeeded()
-
- let sidebar = viewController.sidebarViewControllerForTesting
- guard let workspaceAlpha = store.workspaces.first(where: { $0.cwd == "/tmp/workspace-alpha" }) else {
- Issue.record("workspace-alpha was not loaded.")
- return
- }
- #expect(sidebar.workspaceInsertionIndexForTesting(workspaceAlpha, hoveringBelowMidpoint: false) == 0)
- #expect(sidebar.workspaceInsertionIndexForTesting(workspaceAlpha, hoveringBelowMidpoint: true) == 1)
- }
-
- @Test func workspaceBlankAreaInsertionUsesPointerPosition() {
- let workspaceAlphaJob = makeJob(
- id: "job-workspace-alpha-blank-area",
- cwd: "/tmp/workspace-alpha",
- status: .running,
- targetSummary: "Uncommitted changes"
- )
- let workspaceBetaJob = makeJob(
- id: "job-workspace-beta-blank-area",
- cwd: "/tmp/workspace-beta",
- status: .failed,
- targetSummary: "Base branch: main"
- )
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- content: makeSidebarContent(from: [workspaceBetaJob, workspaceAlphaJob])
- )
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
- viewController.loadViewIfNeeded()
-
- let sidebar = viewController.sidebarViewControllerForTesting
- #expect(sidebar.blankAreaWorkspaceInsertionIndexForTesting(atEnd: false) == 0)
- #expect(sidebar.blankAreaWorkspaceInsertionIndexForTesting(atEnd: true) == store.workspaces.count)
- }
-
- @Test func workspaceDropOnJobRowReordersSection() async {
- let workspaceAlphaJob = makeJob(
- id: "job-workspace-alpha-reject",
- cwd: "/tmp/workspace-alpha",
- status: .running,
- targetSummary: "Uncommitted changes"
- )
- let workspaceBetaJob = makeJob(
- id: "job-workspace-beta-reject",
- cwd: "/tmp/workspace-beta",
- status: .failed,
- targetSummary: "Base branch: main"
- )
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- content: makeSidebarContent(from: [workspaceBetaJob, workspaceAlphaJob])
- )
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
- viewController.loadViewIfNeeded()
-
- let sidebar = viewController.sidebarViewControllerForTesting
- guard let workspaceBeta = store.workspaces.first(where: { $0.cwd == "/tmp/workspace-beta" }),
- let workspaceAlpha = store.workspaces.first(where: { $0.cwd == "/tmp/workspace-alpha" }),
- let betaJob = store.orderedJobs(in: workspaceBeta).first
- else {
- Issue.record("workspace/job state was not loaded.")
- return
- }
-
- #expect(sidebar.displayedSectionTitlesForTesting == [workspaceAlpha.displayTitle, workspaceBeta.displayTitle])
- #expect(sidebar.performWorkspaceDropForTesting(
- workspaceAlpha,
- proposedJob: betaJob,
- hoveringBelowMidpoint: true
- ))
- await Task.yield()
- #expect(sidebar.displayedSectionTitlesForTesting == [workspaceBeta.displayTitle, workspaceAlpha.displayTitle])
- }
-
- @Test func jobDropOnBlankAreaMovesToFullOrderEnd() async {
- let firstJob = makeJob(
- id: "job-blank-area-reject",
- cwd: "/tmp/workspace-alpha",
- status: .running,
- targetSummary: "Uncommitted changes"
- )
- let secondJob = makeJob(
- id: "job-blank-area-peer",
- cwd: "/tmp/workspace-alpha",
- status: .queued,
- targetSummary: "Queued review"
- )
- let workspace = CodexReviewWorkspace(cwd: "/tmp/workspace-alpha")
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- workspaces: [workspace],
- jobs: [firstJob, secondJob]
- )
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
- viewController.loadViewIfNeeded()
-
- let sidebar = viewController.sidebarViewControllerForTesting
- #expect(sidebar.performJobBlankAreaDropForTesting(firstJob))
- await Task.yield()
- #expect(sidebar.displayedJobIDsForTesting(in: workspace) == ["job-blank-area-peer", "job-blank-area-reject"])
- #expect(store.orderedJobs(in: workspace).map(\.id) == ["job-blank-area-peer", "job-blank-area-reject"])
- }
-
- @Test func jobDropReordersWithinWorkspaceAndPreservesSelection() async throws {
- let firstJob = makeJob(
- id: "job-1",
- cwd: "/tmp/workspace-alpha",
- status: .running,
- targetSummary: "Uncommitted changes"
- )
- let secondJob = makeJob(
- id: "job-2",
- cwd: "/tmp/workspace-alpha",
- status: .succeeded,
- targetSummary: "Commit: abc123"
- )
- let workspace = CodexReviewWorkspace(cwd: "/tmp/workspace-alpha")
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- workspaces: [workspace],
- jobs: [firstJob, secondJob]
- )
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
- viewController.loadViewIfNeeded()
-
- let sidebar = viewController.sidebarViewControllerForTesting
- sidebar.selectJobForTesting(firstJob)
- let fullReloadCountBeforeDrop = sidebar.sidebarFullReloadCountForTesting
- let workspaceReloadCountBeforeDrop = sidebar.sidebarWorkspaceReloadCountForTesting
- let incrementalMoveCountBeforeDrop = sidebar.sidebarIncrementalMoveCountForTesting
- let firstJobRowHeightBeforeDrop = try #require(sidebar.jobRowHeightForTesting(firstJob))
- let secondJobRowHeightBeforeDrop = try #require(sidebar.jobRowHeightForTesting(secondJob))
- #expect(firstJobRowHeightBeforeDrop == secondJobRowHeightBeforeDrop)
- #expect(sidebar.performJobDropForTesting(
- firstJob,
- proposedJob: secondJob,
- hoveringBelowMidpoint: false
- ) == false)
- #expect(sidebar.displayedJobIDsForTesting(in: workspace) == ["job-1", "job-2"])
-
- #expect(sidebar.performJobDropForTesting(
- firstJob,
- proposedJob: secondJob,
- hoveringBelowMidpoint: true
- ))
- await Task.yield()
- #expect(sidebar.displayedJobIDsForTesting(in: workspace) == ["job-2", "job-1"])
- #expect(sidebar.selectedJobForTesting?.id == "job-1")
- #expect(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeDrop)
- #expect(sidebar.sidebarWorkspaceReloadCountForTesting == workspaceReloadCountBeforeDrop)
- #expect(sidebar.sidebarIncrementalMoveCountForTesting == incrementalMoveCountBeforeDrop + 1)
- #expect(sidebar.jobRowHeightForTesting(firstJob) == firstJobRowHeightBeforeDrop)
- #expect(sidebar.jobRowHeightForTesting(secondJob) == secondJobRowHeightBeforeDrop)
- }
-
- @Test func sidebarRunningFilterKeepsWorkspacesAndShowsActiveJobs() async throws {
- let runningJob = makeJob(
- id: "job-alpha-running",
- cwd: "/tmp/workspace-alpha",
- status: .running,
- targetSummary: "Uncommitted changes"
- )
- let queuedJob = makeJob(
- id: "job-alpha-queued",
- cwd: "/tmp/workspace-alpha",
- status: .queued,
- targetSummary: "Base branch: main"
- )
- let completedAlphaJob = makeJob(
- id: "job-alpha-succeeded",
- cwd: "/tmp/workspace-alpha",
- status: .succeeded,
- targetSummary: "Commit: abc123"
- )
- let completedBetaJob = makeJob(
- id: "job-beta-succeeded",
- cwd: "/tmp/workspace-beta",
- status: .succeeded,
- targetSummary: "Base branch: main"
- )
- let alphaWorkspace = CodexReviewWorkspace(cwd: "/tmp/workspace-alpha")
- let betaWorkspace = CodexReviewWorkspace(cwd: "/tmp/workspace-beta")
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- workspaces: [alphaWorkspace, betaWorkspace],
- jobs: [runningJob, queuedJob, completedAlphaJob, completedBetaJob]
- )
- let uiState = ReviewMonitorUIState(auth: store.auth)
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
- viewController.loadViewIfNeeded()
-
- let sidebar = viewController.sidebarViewControllerForTesting
- uiState.sidebarJobFilter = .running
-
- try await waitForObservedValueFromCurrentObservation(
- from: { sidebar.sidebarTopologyObservationForTesting },
- ["job-alpha-running", "job-alpha-queued"]
- ) {
- sidebar.displayedJobIDsForTesting(in: alphaWorkspace)
- }
- #expect(sidebar.displayedSectionTitlesForTesting == [
- "workspace-alpha",
- "workspace-beta",
- ])
- #expect(sidebar.displayedJobIDsForTesting(in: alphaWorkspace) == ["job-alpha-running", "job-alpha-queued"])
- #expect(sidebar.displayedJobIDsForTesting(in: betaWorkspace) == [])
- }
-
- @Test func sidebarRunningFilterFollowsJobTerminalStateChanges() async throws {
- let runningJob = makeJob(
- id: "job-filter-state",
- cwd: "/tmp/workspace-alpha",
- status: .running,
- targetSummary: "Uncommitted changes"
- )
- let workspace = CodexReviewWorkspace(cwd: "/tmp/workspace-alpha")
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- workspaces: [workspace],
- jobs: [runningJob]
- )
- let uiState = ReviewMonitorUIState(auth: store.auth)
- uiState.sidebarJobFilter = .running
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
- viewController.loadViewIfNeeded()
-
- let sidebar = viewController.sidebarViewControllerForTesting
- #expect(sidebar.displayedJobIDsForTesting(in: workspace) == ["job-filter-state"])
-
- runningJob.updateStateForTesting(
- status: .succeeded,
- endedAt: Date(timeIntervalSince1970: 201)
- )
- try await waitForObservedValue(
- from: sidebar.sidebarTopologyObservationForTesting,
- [String]()
- ) {
- sidebar.displayedJobIDsForTesting(in: workspace)
- }
-
- runningJob.updateStateForTesting(status: .running, clearEndedAt: true)
- try await waitForObservedValue(
- from: sidebar.sidebarTopologyObservationForTesting,
- ["job-filter-state"]
- ) {
- sidebar.displayedJobIDsForTesting(in: workspace)
- }
- }
-
- @Test func sidebarRunningFilterDoesNotClearHiddenSelectedJob() async throws {
- let runningJob = makeJob(
- id: "job-filter-running",
- cwd: "/tmp/workspace-alpha",
- status: .running,
- targetSummary: "Uncommitted changes"
- )
- let completedJob = makeJob(
- id: "job-filter-completed",
- cwd: "/tmp/workspace-alpha",
- status: .succeeded,
- targetSummary: "Commit: abc123"
- )
- let workspace = CodexReviewWorkspace(cwd: "/tmp/workspace-alpha")
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- workspaces: [workspace],
- jobs: [runningJob, completedJob]
- )
- let uiState = ReviewMonitorUIState(auth: store.auth)
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
- viewController.loadViewIfNeeded()
-
- let sidebar = viewController.sidebarViewControllerForTesting
- sidebar.selectJobForTesting(completedJob)
- #expect(sidebar.selectedJobForTesting?.id == "job-filter-completed")
-
- uiState.sidebarJobFilter = .running
- try await waitForObservedValueFromCurrentObservation(
- from: { sidebar.sidebarTopologyObservationForTesting },
- ["job-filter-running"]
- ) {
- sidebar.displayedJobIDsForTesting(in: workspace)
- }
-
- #expect(sidebar.displayedJobIDsForTesting(in: workspace) == ["job-filter-running"])
- #expect(sidebar.selectedJobForTesting?.id == "job-filter-completed")
- }
-
- @Test func sidebarLatestFinishedFilterKeepsWorkspacesAndShowsLatestTerminalJob() async throws {
- let alphaRunningJob = makeJob(
- id: "job-alpha-running",
- cwd: "/tmp/workspace-alpha",
- startedAt: Date(timeIntervalSince1970: 400),
- status: .running,
- targetSummary: "Running alpha"
- )
- let alphaQueuedJob = makeJob(
- id: "job-alpha-queued",
- cwd: "/tmp/workspace-alpha",
- startedAt: Date(timeIntervalSince1970: 500),
- status: .queued,
- targetSummary: "Queued alpha"
- )
- let alphaSucceededJob = makeJob(
- id: "job-alpha-succeeded",
- cwd: "/tmp/workspace-alpha",
- startedAt: Date(timeIntervalSince1970: 100),
- status: .succeeded,
- targetSummary: "Succeeded alpha"
- )
- let alphaCancelledJob = makeJob(
- id: "job-alpha-cancelled",
- cwd: "/tmp/workspace-alpha",
- startedAt: Date(timeIntervalSince1970: 200),
- status: .cancelled,
- targetSummary: "Cancelled alpha"
- )
- let alphaFailedJob = makeJob(
- id: "job-alpha-failed",
- cwd: "/tmp/workspace-alpha",
- startedAt: Date(timeIntervalSince1970: 300),
- status: .failed,
- targetSummary: "Failed alpha"
- )
- alphaFailedJob.updateStateForTesting(clearEndedAt: true)
- let betaCancelledJob = makeJob(
- id: "job-beta-cancelled",
- cwd: "/tmp/workspace-beta",
- startedAt: Date(timeIntervalSince1970: 250),
- status: .cancelled,
- targetSummary: "Cancelled beta"
- )
- let gammaRunningJob = makeJob(
- id: "job-gamma-running",
- cwd: "/tmp/workspace-gamma",
- startedAt: Date(timeIntervalSince1970: 600),
- status: .running,
- targetSummary: "Running gamma"
- )
- let alphaWorkspace = CodexReviewWorkspace(cwd: "/tmp/workspace-alpha")
- let betaWorkspace = CodexReviewWorkspace(cwd: "/tmp/workspace-beta")
- let gammaWorkspace = CodexReviewWorkspace(cwd: "/tmp/workspace-gamma")
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- workspaces: [alphaWorkspace, betaWorkspace, gammaWorkspace],
- jobs: [
- alphaRunningJob,
- alphaQueuedJob,
- alphaSucceededJob,
- alphaCancelledJob,
- alphaFailedJob,
- betaCancelledJob,
- gammaRunningJob,
- ]
- )
- let uiState = ReviewMonitorUIState(auth: store.auth)
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
- viewController.loadViewIfNeeded()
-
- let sidebar = viewController.sidebarViewControllerForTesting
- sidebar.selectJobForTesting(alphaRunningJob)
- uiState.sidebarJobFilter = .latestFinished
-
- try await waitForObservedValueFromCurrentObservation(
- from: { sidebar.sidebarTopologyObservationForTesting },
- ["job-alpha-failed"]
- ) {
- sidebar.displayedJobIDsForTesting(in: alphaWorkspace)
- }
- #expect(sidebar.displayedSectionTitlesForTesting == [
- "workspace-alpha",
- "workspace-beta",
- "workspace-gamma",
- ])
- #expect(sidebar.displayedJobIDsForTesting(in: alphaWorkspace) == ["job-alpha-failed"])
- #expect(sidebar.displayedJobIDsForTesting(in: betaWorkspace) == ["job-beta-cancelled"])
- #expect(sidebar.displayedJobIDsForTesting(in: gammaWorkspace) == [])
- #expect(sidebar.selectedJobForTesting?.id == "job-alpha-running")
- }
-
- @Test func sidebarLatestFinishedFilterFollowsTerminalDateChanges() async throws {
- let firstJob = makeJob(
- id: "job-finished-first",
- cwd: "/tmp/workspace-alpha",
- startedAt: Date(timeIntervalSince1970: 100),
- status: .succeeded,
- targetSummary: "First finished"
- )
- let secondJob = makeJob(
- id: "job-finished-second",
- cwd: "/tmp/workspace-alpha",
- startedAt: Date(timeIntervalSince1970: 200),
- status: .failed,
- targetSummary: "Second finished"
- )
- let runningJob = makeJob(
- id: "job-running",
- cwd: "/tmp/workspace-alpha",
- startedAt: Date(timeIntervalSince1970: 300),
- status: .running,
- targetSummary: "Running"
- )
- let workspace = CodexReviewWorkspace(cwd: "/tmp/workspace-alpha")
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- workspaces: [workspace],
- jobs: [firstJob, secondJob, runningJob]
- )
- let uiState = ReviewMonitorUIState(auth: store.auth)
- uiState.sidebarJobFilter = .latestFinished
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
- viewController.loadViewIfNeeded()
-
- let sidebar = viewController.sidebarViewControllerForTesting
- #expect(sidebar.displayedJobIDsForTesting(in: workspace) == ["job-finished-second"])
-
- firstJob.updateStateForTesting(endedAt: Date(timeIntervalSince1970: 400))
- try await waitForObservedValue(
- from: sidebar.sidebarTopologyObservationForTesting,
- ["job-finished-first"]
- ) {
- sidebar.displayedJobIDsForTesting(in: workspace)
- }
-
- runningJob.updateStateForTesting(
- status: .cancelled,
- endedAt: Date(timeIntervalSince1970: 500)
- )
- try await waitForObservedValue(
- from: sidebar.sidebarTopologyObservationForTesting,
- ["job-running"]
- ) {
- sidebar.displayedJobIDsForTesting(in: workspace)
- }
- }
-
- @Test func sidebarRunningAndLatestFinishedFiltersCombineVisibleJobs() async throws {
- let runningJob = makeJob(
- id: "job-running",
- cwd: "/tmp/workspace-alpha",
- startedAt: Date(timeIntervalSince1970: 400),
- status: .running,
- targetSummary: "Running"
- )
- let queuedJob = makeJob(
- id: "job-queued",
- cwd: "/tmp/workspace-alpha",
- startedAt: Date(timeIntervalSince1970: 500),
- status: .queued,
- targetSummary: "Queued"
- )
- let olderFinishedJob = makeJob(
- id: "job-finished-older",
- cwd: "/tmp/workspace-alpha",
- startedAt: Date(timeIntervalSince1970: 100),
- status: .succeeded,
- targetSummary: "Older finished"
- )
- let latestFinishedJob = makeJob(
- id: "job-finished-latest",
- cwd: "/tmp/workspace-alpha",
- startedAt: Date(timeIntervalSince1970: 300),
- status: .failed,
- targetSummary: "Latest finished"
- )
- let workspace = CodexReviewWorkspace(cwd: "/tmp/workspace-alpha")
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- workspaces: [workspace],
- jobs: [runningJob, olderFinishedJob, queuedJob, latestFinishedJob]
- )
- let uiState = ReviewMonitorUIState(auth: store.auth)
- uiState.sidebarJobFilter = [.running, .latestFinished]
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
- viewController.loadViewIfNeeded()
-
- let sidebar = viewController.sidebarViewControllerForTesting
- #expect(sidebar.displayedJobIDsForTesting(in: workspace) == [
- "job-running",
- "job-queued",
- "job-finished-latest",
- ])
- }
-
- @Test func jobDropWhileFilteredMapsVisibleIndexToStoreOrder() async throws {
- let hiddenPrefix = makeJob(
- id: "job-hidden-prefix",
- cwd: "/tmp/workspace-alpha",
- status: .succeeded,
- targetSummary: "Completed prefix"
- )
- let runningA = makeJob(
- id: "job-running-a",
- cwd: "/tmp/workspace-alpha",
- status: .running,
- targetSummary: "Running A"
- )
- let hiddenMiddle = makeJob(
- id: "job-hidden-middle",
- cwd: "/tmp/workspace-alpha",
- status: .failed,
- targetSummary: "Failed middle"
- )
- let runningB = makeJob(
- id: "job-running-b",
- cwd: "/tmp/workspace-alpha",
- status: .running,
- targetSummary: "Running B"
- )
- let hiddenSuffix = makeJob(
- id: "job-hidden-suffix",
- cwd: "/tmp/workspace-alpha",
- status: .succeeded,
- targetSummary: "Completed suffix"
- )
- let workspace = CodexReviewWorkspace(cwd: "/tmp/workspace-alpha")
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- workspaces: [workspace],
- jobs: [hiddenPrefix, runningA, hiddenMiddle, runningB, hiddenSuffix]
- )
- let uiState = ReviewMonitorUIState(auth: store.auth)
- uiState.sidebarJobFilter = .running
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
- viewController.loadViewIfNeeded()
-
- let sidebar = viewController.sidebarViewControllerForTesting
- #expect(sidebar.displayedJobIDsForTesting(in: workspace) == ["job-running-a", "job-running-b"])
-
- #expect(sidebar.performJobDropForTesting(
- runningA,
- proposedJob: runningB,
- hoveringBelowMidpoint: false
- ) == false)
- await Task.yield()
-
- #expect(sidebar.displayedJobIDsForTesting(in: workspace) == ["job-running-a", "job-running-b"])
- #expect(store.orderedJobs(in: workspace).map(\.id) == [
- "job-hidden-prefix",
- "job-running-a",
- "job-hidden-middle",
- "job-running-b",
- "job-hidden-suffix",
- ])
-
- #expect(sidebar.performJobDropForTesting(
- runningA,
- proposedJob: runningB,
- hoveringBelowMidpoint: true
- ))
- await Task.yield()
-
- #expect(sidebar.displayedJobIDsForTesting(in: workspace) == ["job-running-b", "job-running-a"])
- #expect(store.orderedJobs(in: workspace).map(\.id) == [
- "job-hidden-prefix",
- "job-hidden-middle",
- "job-running-b",
- "job-running-a",
- "job-hidden-suffix",
- ])
-
- #expect(sidebar.performJobBlankAreaDropForTesting(runningA))
- await Task.yield()
-
- #expect(sidebar.displayedJobIDsForTesting(in: workspace) == ["job-running-b", "job-running-a"])
- #expect(store.orderedJobs(in: workspace).map(\.id) == [
- "job-hidden-prefix",
- "job-hidden-middle",
- "job-running-b",
- "job-hidden-suffix",
- "job-running-a",
- ])
- }
-
- @Test func jobDropIsRejectedForLatestFinishedOnlyFilter() {
- let runningJob = makeJob(
- id: "job-running",
- cwd: "/tmp/workspace-alpha",
- startedAt: Date(timeIntervalSince1970: 300),
- status: .running,
- targetSummary: "Running"
- )
- let olderJob = makeJob(
- id: "job-finished-older",
- cwd: "/tmp/workspace-alpha",
- startedAt: Date(timeIntervalSince1970: 100),
- status: .succeeded,
- targetSummary: "Older finished"
- )
- let latestJob = makeJob(
- id: "job-finished-latest",
- cwd: "/tmp/workspace-alpha",
- startedAt: Date(timeIntervalSince1970: 200),
- status: .failed,
- targetSummary: "Latest finished"
- )
- let workspace = CodexReviewWorkspace(cwd: "/tmp/workspace-alpha")
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- workspaces: [workspace],
- jobs: [runningJob, olderJob, latestJob]
- )
- let uiState = ReviewMonitorUIState(auth: store.auth)
- uiState.sidebarJobFilter = .latestFinished
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
- viewController.loadViewIfNeeded()
-
- let sidebar = viewController.sidebarViewControllerForTesting
- #expect(sidebar.displayedJobIDsForTesting(in: workspace) == ["job-finished-latest"])
- #expect(sidebar.performJobDropForTesting(latestJob, proposedWorkspace: workspace, childIndex: 0) == false)
- }
-
- @Test func jobDropWhileRunningAndLatestFinishedFilterReordersVisibleJobs() async throws {
- let runningJob = makeJob(
- id: "job-running",
- cwd: "/tmp/workspace-alpha",
- startedAt: Date(timeIntervalSince1970: 300),
- status: .running,
- targetSummary: "Running"
- )
- let olderJob = makeJob(
- id: "job-finished-older",
- cwd: "/tmp/workspace-alpha",
- startedAt: Date(timeIntervalSince1970: 100),
- status: .succeeded,
- targetSummary: "Older finished"
- )
- let latestJob = makeJob(
- id: "job-finished-latest",
- cwd: "/tmp/workspace-alpha",
- startedAt: Date(timeIntervalSince1970: 200),
- status: .failed,
- targetSummary: "Latest finished"
- )
- let workspace = CodexReviewWorkspace(cwd: "/tmp/workspace-alpha")
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- workspaces: [workspace],
- jobs: [runningJob, olderJob, latestJob]
- )
- let uiState = ReviewMonitorUIState(auth: store.auth)
- uiState.sidebarJobFilter = [.running, .latestFinished]
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
- viewController.loadViewIfNeeded()
-
- let sidebar = viewController.sidebarViewControllerForTesting
- #expect(sidebar.displayedJobIDsForTesting(in: workspace) == ["job-running", "job-finished-latest"])
- #expect(sidebar.performJobDropForTesting(runningJob, proposedWorkspace: workspace, childIndex: 2))
- await Task.yield()
-
- try await waitForObservedValueFromCurrentObservation(
- from: { sidebar.sidebarTopologyObservationForTesting },
- ["job-finished-latest", "job-running"]
- ) {
- sidebar.displayedJobIDsForTesting(in: workspace)
- }
- #expect(store.orderedJobs(in: workspace).map(\.id) == [
- "job-finished-older",
- "job-finished-latest",
- "job-running",
- ])
- }
-
- @Test func jobSameMembershipSortOrderChangeMovesRowsWithoutReloadingWorkspace() async throws {
- let firstJob = makeJob(
- id: "job-sort-order-1",
- cwd: "/tmp/workspace-alpha",
- status: .running,
- targetSummary: "Uncommitted changes"
- )
- let secondJob = makeJob(
- id: "job-sort-order-2",
- cwd: "/tmp/workspace-alpha",
- status: .succeeded,
- targetSummary: "Commit: abc123"
- )
- let workspace = CodexReviewWorkspace(cwd: "/tmp/workspace-alpha")
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- workspaces: [workspace],
- jobs: [firstJob, secondJob]
- )
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
- viewController.loadViewIfNeeded()
-
- let sidebar = viewController.sidebarViewControllerForTesting
- let fullReloadCountBeforeChange = sidebar.sidebarFullReloadCountForTesting
- let workspaceReloadCountBeforeChange = sidebar.sidebarWorkspaceReloadCountForTesting
- let incrementalMoveCountBeforeChange = sidebar.sidebarIncrementalMoveCountForTesting
- let incrementalMembershipChangeCountBeforeChange = sidebar.sidebarIncrementalMembershipChangeCountForTesting
-
- store.loadForTesting(
- serverState: .running,
- workspaces: [workspace],
- jobs: [secondJob, firstJob]
- )
- try await waitForObservedValue(
- from: sidebar.sidebarTopologyObservationForTesting,
- [
- "job-sort-order-2",
- "job-sort-order-1",
- ]
- ) {
- sidebar.displayedJobIDsForTesting(in: workspace)
- }
-
- #expect(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeChange)
- #expect(sidebar.sidebarWorkspaceReloadCountForTesting == workspaceReloadCountBeforeChange)
- #expect(sidebar.sidebarIncrementalMembershipChangeCountForTesting == incrementalMembershipChangeCountBeforeChange)
- #expect(sidebar.sidebarIncrementalMoveCountForTesting == incrementalMoveCountBeforeChange + 1)
- }
-
- @Test func workspaceJobsChangeUsesChildInsertWithoutReload() async throws {
- let firstJob = makeJob(
- id: "job-membership-1",
- cwd: "/tmp/workspace-alpha",
- status: .running,
- targetSummary: "Uncommitted changes"
- )
- let secondJob = makeJob(
- id: "job-membership-2",
- cwd: "/tmp/workspace-alpha",
- status: .queued,
- targetSummary: "Queued review"
- )
- let workspace = CodexReviewWorkspace(cwd: "/tmp/workspace-alpha")
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- workspaces: [workspace],
- jobs: [firstJob]
- )
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
- viewController.loadViewIfNeeded()
-
- let sidebar = viewController.sidebarViewControllerForTesting
- let fullReloadCountBeforeChange = sidebar.sidebarFullReloadCountForTesting
- let workspaceReloadCountBeforeChange = sidebar.sidebarWorkspaceReloadCountForTesting
- let incrementalMoveCountBeforeChange = sidebar.sidebarIncrementalMoveCountForTesting
- let incrementalMembershipChangeCountBeforeChange = sidebar.sidebarIncrementalMembershipChangeCountForTesting
-
- store.loadForTesting(
- serverState: .running,
- workspaces: [workspace],
- jobs: [firstJob, secondJob]
- )
- try await waitForObservedValue(
- from: sidebar.sidebarTopologyObservationForTesting,
- [
- "job-membership-1",
- "job-membership-2",
- ]
- ) {
- sidebar.displayedJobIDsForTesting(in: workspace)
- }
-
- #expect(sidebar.displayedJobIDsForTesting(in: workspace) == ["job-membership-1", "job-membership-2"])
- #expect(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeChange)
- #expect(sidebar.sidebarWorkspaceReloadCountForTesting == workspaceReloadCountBeforeChange)
- #expect(sidebar.sidebarIncrementalMembershipChangeCountForTesting == incrementalMembershipChangeCountBeforeChange + 1)
- #expect(sidebar.sidebarIncrementalMoveCountForTesting == incrementalMoveCountBeforeChange)
- }
-
- @Test func addAccountToolbarItemShowsProgressPresentation() async throws {
- let store = CodexReviewStore.makePreviewStore()
- let activeAccount = CodexAccount(email: "first@example.com", planType: "pro")
- store.loadForTesting(
- serverState: .running,
- authPhase: .signingIn(
- .init(
- title: "Sign in with ChatGPT",
- detail: "Open the browser to continue."
- )
- ),
- account: activeAccount,
- persistedAccounts: [activeAccount],
- workspaces: []
- )
-
- let uiState = ReviewMonitorUIState(auth: store.auth)
- uiState.sidebarSelection = .account
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
- let window = NSWindow(contentViewController: viewController)
- defer { window.close() }
- window.setContentSize(NSSize(width: 900, height: 600))
-
- viewController.attach(to: window)
- let sidebarItem = try #require(viewController.splitViewItems.first)
- sidebarItem.isCollapsed = false
- window.layoutIfNeeded()
- try await waitForAddAccountToolbarItemHidden(viewController, false)
-
- #expect(viewController.addAccountToolbarItemModeForTesting == .progress)
- }
-
- @Test func addAccountToolbarItemDoesNotStickInProgressModeWhenAuthenticationEndsImmediately() async throws {
- let store = CodexReviewStore.makePreviewStore()
- let activeAccount = CodexAccount(email: "first@example.com", planType: "pro")
- store.loadForTesting(
- serverState: .running,
- authPhase: .signedOut,
- account: activeAccount,
- persistedAccounts: [activeAccount],
- workspaces: []
- )
-
- let uiState = ReviewMonitorUIState(auth: store.auth)
- uiState.sidebarSelection = .account
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
- let window = NSWindow(contentViewController: viewController)
- defer { window.close() }
- window.setContentSize(NSSize(width: 900, height: 600))
-
- viewController.attach(to: window)
- let sidebarItem = try #require(viewController.splitViewItems.first)
- sidebarItem.isCollapsed = false
- window.layoutIfNeeded()
- try await waitForAddAccountToolbarItemHidden(viewController, false)
-
- #expect(viewController.addAccountToolbarItemModeForTesting == .add)
-
- store.auth.updatePhase(
- .signingIn(
- .init(
- title: "Sign in with ChatGPT",
- detail: "Open the browser to continue."
- )
- )
- )
- store.auth.updatePhase(.signedOut)
-
- try await waitForAddAccountToolbarMode(viewController, .add)
- #expect(viewController.addAccountToolbarItemModeForTesting == .add)
- }
-
- @Test func addAccountToolbarItemProvidesOverflowMenuFallback() async throws {
- let store = CodexReviewStore.makePreviewStore()
- let activeAccount = CodexAccount(email: "first@example.com", planType: "pro")
- store.loadForTesting(
- serverState: .running,
- authPhase: .signedOut,
- account: activeAccount,
- persistedAccounts: [activeAccount],
- workspaces: []
- )
-
- let uiState = ReviewMonitorUIState(auth: store.auth)
- uiState.sidebarSelection = .account
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
- let window = NSWindow(contentViewController: viewController)
- defer { window.close() }
- window.setContentSize(NSSize(width: 900, height: 600))
-
- viewController.attach(to: window)
- let sidebarItem = try #require(viewController.splitViewItems.first)
- sidebarItem.isCollapsed = false
- window.layoutIfNeeded()
- try await waitForAddAccountToolbarItemHidden(viewController, false)
- try await waitForAddAccountToolbarMode(viewController, .add)
-
- #expect(viewController.addAccountToolbarMenuTitleForTesting == "Add Account")
-
- store.auth.updatePhase(
- .signingIn(
- .init(
- title: "Sign in with ChatGPT",
- detail: "Open the browser to continue."
- )
- )
- )
-
- try await waitForAddAccountToolbarMode(viewController, .progress)
- #expect(viewController.addAccountToolbarMenuTitleForTesting == "Cancel Sign-In")
- }
-
- @Test func addAccountToolbarItemStaysVisibleDuringAuthenticationOutsideAccountSidebar() async throws {
- let store = CodexReviewStore.makePreviewStore()
- let activeAccount = CodexAccount(email: "first@example.com", planType: "pro")
- store.loadForTesting(
- serverState: .running,
- authPhase: .signingIn(
- .init(
- title: "Sign in with ChatGPT",
- detail: "Open the browser to continue."
- )
- ),
- account: activeAccount,
- persistedAccounts: [activeAccount],
- workspaces: []
- )
-
- let uiState = ReviewMonitorUIState(auth: store.auth)
- uiState.sidebarSelection = .workspace
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
- let window = NSWindow(contentViewController: viewController)
- defer { window.close() }
- window.setContentSize(NSSize(width: 900, height: 600))
-
- viewController.attach(to: window)
- let sidebarItem = try #require(viewController.splitViewItems.first)
- sidebarItem.isCollapsed = true
- window.layoutIfNeeded()
-
- #expect(viewController.addAccountToolbarItemIsHiddenForTesting == false)
- #expect(viewController.addAccountToolbarItemModeForTesting == .progress)
- }
-
- @Test func addAccountToolbarItemRehidesAfterAuthenticationEndsOutsideAccountSidebar() async throws {
- let store = CodexReviewStore.makePreviewStore()
- let activeAccount = CodexAccount(email: "first@example.com", planType: "pro")
- store.loadForTesting(
- serverState: .running,
- authPhase: .signingIn(
- .init(
- title: "Sign in with ChatGPT",
- detail: "Open the browser to continue."
- )
- ),
- account: activeAccount,
- persistedAccounts: [activeAccount],
- workspaces: []
- )
-
- let uiState = ReviewMonitorUIState(auth: store.auth)
- uiState.sidebarSelection = .workspace
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
- let window = NSWindow(contentViewController: viewController)
- defer { window.close() }
- window.setContentSize(NSSize(width: 900, height: 600))
-
- viewController.attach(to: window)
- let sidebarItem = try #require(viewController.splitViewItems.first)
- sidebarItem.isCollapsed = true
- window.layoutIfNeeded()
-
- #expect(viewController.addAccountToolbarItemIsHiddenForTesting == false)
-
- store.auth.updatePhase(.signedOut)
-
- try await waitForCondition {
- viewController.addAccountToolbarItemIsHiddenForTesting
- }
- #expect(viewController.addAccountToolbarItemIsHiddenForTesting)
- }
-
- @Test func accountSidebarUsesOutlineViewRows() throws {
- let activeAccount = CodexAccount(email: "active@example.com", planType: "pro")
- let otherAccount = CodexAccount(email: "other@example.com", planType: "plus")
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- account: activeAccount,
- persistedAccounts: [activeAccount, otherAccount],
- workspaces: []
- )
- let uiState = ReviewMonitorUIState(auth: store.auth)
- uiState.sidebarSelection = .account
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
- viewController.loadViewIfNeeded()
-
- let accountsViewController = viewController
- .sidebarViewControllerForTesting
- .accountsViewControllerForTesting
- let displayedActiveAccount = try #require(
- store.auth.persistedAccounts.first { $0.email == "active@example.com" }
- )
-
- #expect(accountsViewController.accountListUsesOutlineViewForTesting)
- #expect(accountsViewController.displayedAccountEmailsForTesting == [
- "active@example.com",
- "other@example.com",
- ])
- #expect(accountsViewController.accountRowUsesReviewMonitorAccountCellViewForTesting(displayedActiveAccount))
- #expect(accountsViewController.accountRowUsesSwiftUIRowViewForTesting(displayedActiveAccount))
- }
-
- @Test func accountDropReordersToDisplayedGapForDownwardMove() async throws {
- let firstAccount = CodexAccount(email: "first@example.com", planType: "pro")
- let secondAccount = CodexAccount(email: "second@example.com", planType: "plus")
- let thirdAccount = CodexAccount(email: "third@example.com", planType: "team")
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- account: firstAccount,
- persistedAccounts: [firstAccount, secondAccount, thirdAccount],
- workspaces: []
- )
- let uiState = ReviewMonitorUIState(auth: store.auth)
- uiState.sidebarSelection = .account
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
- viewController.loadViewIfNeeded()
-
- let accountsViewController = viewController
- .sidebarViewControllerForTesting
- .accountsViewControllerForTesting
- let displayedFirstAccount = try #require(
- store.auth.persistedAccounts.first { $0.email == "first@example.com" }
- )
- let fullReloadCountBeforeDrop = accountsViewController.accountFullReloadCountForTesting
- let incrementalMembershipChangeCountBeforeDrop = accountsViewController
- .accountIncrementalMembershipChangeCountForTesting
- let incrementalMoveCountBeforeDrop = accountsViewController.accountIncrementalMoveCountForTesting
-
- #expect(await accountsViewController.performAccountDropForTesting(
- displayedFirstAccount,
- proposedChildIndex: 2
- ))
- #expect(store.auth.persistedAccounts.map(\.email) == [
- "second@example.com",
- "first@example.com",
- "third@example.com",
- ])
- try await waitForObservedValue(
- from: accountsViewController.accountListObservationForTesting,
- [
- "second@example.com",
- "first@example.com",
- "third@example.com",
- ]
- ) {
- accountsViewController.displayedAccountEmailsForTesting
- }
- #expect(accountsViewController.accountFullReloadCountForTesting == fullReloadCountBeforeDrop)
- #expect(
- accountsViewController.accountIncrementalMembershipChangeCountForTesting ==
- incrementalMembershipChangeCountBeforeDrop
- )
- #expect(accountsViewController.accountIncrementalMoveCountForTesting == incrementalMoveCountBeforeDrop + 1)
- }
-
- @Test func accountDropBeforeDetachedCurrentSessionMovesToLastSavedPosition() async throws {
- let firstAccount = CodexAccount(email: "first@example.com", planType: "pro")
- let secondAccount = CodexAccount(email: "second@example.com", planType: "plus")
- let detachedAccount = CodexAccount(email: "detached@example.com", planType: "team")
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- account: detachedAccount,
- persistedAccounts: [firstAccount, secondAccount],
- workspaces: []
- )
- let uiState = ReviewMonitorUIState(auth: store.auth)
- uiState.sidebarSelection = .account
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
- viewController.loadViewIfNeeded()
-
- let accountsViewController = viewController
- .sidebarViewControllerForTesting
- .accountsViewControllerForTesting
- let displayedFirstAccount = try #require(
- store.auth.persistedAccounts.first { $0.email == "first@example.com" }
- )
-
- #expect(accountsViewController.displayedAccountEmailsForTesting == [
- "first@example.com",
- "second@example.com",
- "detached@example.com",
- ])
- #expect(await accountsViewController.performAccountDropForTesting(
- displayedFirstAccount,
- proposedItem: detachedAccount,
- proposedChildIndex: NSOutlineViewDropOnItemIndex
- ))
- #expect(store.auth.persistedAccounts.map(\.email) == [
- "second@example.com",
- "first@example.com",
- ])
- try await waitForObservedValue(
- from: accountsViewController.accountListObservationForTesting,
- [
- "second@example.com",
- "first@example.com",
- "detached@example.com",
- ]
- ) {
- accountsViewController.displayedAccountEmailsForTesting
- }
- #expect(accountsViewController.displayedAccountEmailsForTesting == [
- "second@example.com",
- "first@example.com",
- "detached@example.com",
- ])
- }
-
- @Test func jobCellViewUpdatesHostedObservationReferenceWithoutReplacingHostingView() throws {
- let placeholderJob = makeJob(
- id: "job-placeholder",
- status: .queued,
- targetSummary: "Queued review"
- )
- let loadedJob = makeJob(
- id: "job-loaded",
- status: .running,
- targetSummary: "Uncommitted changes"
- )
-
- let cellView = makeReviewMonitorJobCellViewForTesting(job: placeholderJob)
- let initialHostingViewIdentity = try #require(
- reviewMonitorJobCellHostingViewIdentityForTesting(cellView)
- )
- let initialHostedJobID = reviewMonitorJobCellHostedJobIDForTesting(cellView)
-
- configureReviewMonitorJobCellViewForTesting(cellView, job: loadedJob)
-
- let updatedHostingViewIdentity = try #require(
- reviewMonitorJobCellHostingViewIdentityForTesting(cellView)
- )
- let updatedHostedJobID = reviewMonitorJobCellHostedJobIDForTesting(cellView)
-
- #expect(initialHostedJobID == placeholderJob.id)
- #expect(updatedHostedJobID == loadedJob.id)
- #expect(initialHostingViewIdentity == updatedHostingViewIdentity)
- #expect(cellView.objectValue as? CodexReviewJob === loadedJob)
- #expect(cellView.toolTip == loadedJob.cwd)
- }
-
- @Test func workspaceDropPreservesExpansionState() {
- let alphaJob = makeJob(
- id: "job-alpha",
- cwd: "/tmp/workspace-alpha",
- status: .running,
- targetSummary: "Uncommitted changes"
- )
- let betaJob = makeJob(
- id: "job-beta",
- cwd: "/tmp/workspace-beta",
- status: .running,
- targetSummary: "Uncommitted changes"
- )
- let alphaWorkspace = CodexReviewWorkspace(cwd: alphaJob.cwd)
- let betaWorkspace = CodexReviewWorkspace(cwd: betaJob.cwd)
-
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- workspaces: [alphaWorkspace, betaWorkspace],
- jobs: [alphaJob, betaJob]
- )
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
- viewController.loadViewIfNeeded()
-
- let sidebar = viewController.sidebarViewControllerForTesting
- sidebar.collapseWorkspaceInOutlineForTesting(betaWorkspace)
- #expect(sidebar.workspaceIsExpandedForTesting(betaWorkspace) == false)
- #expect(sidebar.performWorkspaceDropForTesting(betaWorkspace, toIndex: 0))
- #expect(sidebar.workspaceIsExpandedForTesting(betaWorkspace) == false)
- }
-
- @Test func crossWorkspaceJobDropIsRejected() {
- let alphaJob = makeJob(
- id: "job-alpha",
- cwd: "/tmp/workspace-alpha",
- status: .running,
- targetSummary: "Uncommitted changes"
- )
- let betaJob = makeJob(
- id: "job-beta",
- cwd: "/tmp/workspace-beta",
- status: .running,
- targetSummary: "Base branch: main"
- )
- let alphaWorkspace = CodexReviewWorkspace(cwd: alphaJob.cwd)
- let betaWorkspace = CodexReviewWorkspace(cwd: betaJob.cwd)
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- workspaces: [alphaWorkspace, betaWorkspace],
- jobs: [alphaJob, betaJob]
- )
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
- viewController.loadViewIfNeeded()
-
- let sidebar = viewController.sidebarViewControllerForTesting
- #expect(sidebar.performJobDropForTesting(alphaJob, proposedWorkspace: betaWorkspace, childIndex: 0) == false)
- #expect(store.orderedJobs(in: alphaWorkspace).map(\.id) == ["job-alpha"])
- #expect(store.orderedJobs(in: betaWorkspace).map(\.id) == ["job-beta"])
- }
-
- @Test func sidebarWorkspaceRowsStayExpandedAndUseExpectedCellViews() {
- let job = makeJob(
- cwd: "/tmp/workspace-alpha",
- status: .running,
- targetSummary: "Uncommitted changes"
- )
- let workspace = CodexReviewWorkspace(cwd: job.cwd)
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- workspaces: [workspace],
- jobs: [job]
- )
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
- viewController.loadViewIfNeeded()
-
- let sidebar = viewController.sidebarViewControllerForTesting
- #expect(sidebar.allWorkspaceRowsExpandedForTesting)
- #expect(sidebar.workspaceIsSelectableForTesting(workspace))
- #expect(sidebar.floatsGroupRowsEnabledForTesting == false)
- #expect(sidebar.draggingDestinationFeedbackStyleForTesting == .sourceList)
- #expect(sidebar.sidebarUsesAutomaticRowHeightsForTesting == false)
- #expect(sidebar.jobRowUsesReviewMonitorJobRowViewForTesting(job))
- }
-
- @Test func sidebarUsesMeasuredRowHeightsForWorkspaceAndJobRows() throws {
- let job = makeJob(
- cwd: "/tmp/workspace-alpha",
- status: .running,
- targetSummary: "Uncommitted changes"
- )
- let workspace = CodexReviewWorkspace(cwd: job.cwd)
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- workspaces: [workspace],
- jobs: [job]
- )
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
- let window = NSWindow(contentViewController: viewController)
- defer { window.close() }
- window.setContentSize(NSSize(width: 360, height: 260))
- viewController.loadViewIfNeeded()
- viewController.view.layoutSubtreeIfNeeded()
-
- let sidebar = viewController.sidebarViewControllerForTesting
- let workspaceRowHeight = try #require(sidebar.workspaceRowHeightForTesting(workspace))
- let jobRowHeight = try #require(sidebar.jobRowHeightForTesting(job))
- #expect(workspaceRowHeight == sidebar.expectedWorkspaceRowRectHeightForTesting)
- #expect(jobRowHeight == sidebar.expectedJobRowRectHeightForTesting)
- #expect(workspaceRowHeight < jobRowHeight)
- }
-
- @Test func jobRowsUseLabelIconSlotInsteadOfOutlineChildIndent() throws {
- let job = makeJob(
- cwd: "/tmp/workspace-alpha",
- status: .running,
- targetSummary: "Uncommitted changes"
- )
- let workspace = CodexReviewWorkspace(cwd: job.cwd)
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- workspaces: [workspace],
- jobs: [job]
- )
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
- let window = NSWindow(contentViewController: viewController)
- defer { window.close() }
- window.setContentSize(NSSize(width: 360, height: 260))
- viewController.loadViewIfNeeded()
- viewController.view.layoutSubtreeIfNeeded()
-
- let sidebar = viewController.sidebarViewControllerForTesting
- let workspaceCellMinX = try #require(sidebar.workspaceCellMinXForTesting(workspace))
- let jobCellMinX = try #require(sidebar.jobCellMinXForTesting(job))
- #expect(jobCellMinX < workspaceCellMinX)
- }
-
- @Test func workspaceContentStartsAfterNativeDisclosureGutter() throws {
- let job = makeJob(
- cwd: "/tmp/workspace-alpha",
- status: .running,
- targetSummary: "Uncommitted changes"
- )
- let workspace = CodexReviewWorkspace(cwd: job.cwd)
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- workspaces: [workspace],
- jobs: [job]
- )
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
- let window = NSWindow(contentViewController: viewController)
- defer { window.close() }
- window.setContentSize(NSSize(width: 360, height: 260))
- viewController.loadViewIfNeeded()
- viewController.view.layoutSubtreeIfNeeded()
-
- let sidebar = viewController.sidebarViewControllerForTesting
- let workspaceCellMinX = try #require(sidebar.workspaceCellMinXForTesting(workspace))
- let disclosureMaxX = try #require(sidebar.workspaceDisclosureMaxXForTesting(workspace))
- #expect(workspaceCellMinX >= disclosureMaxX - 0.5)
- }
-
- @Test func workspaceDisclosureStaysNativeWhenWorkspaceHasNoJobs() throws {
- let workspace = CodexReviewWorkspace(cwd: "/tmp/workspace-alpha")
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- workspaces: [workspace],
- jobs: []
- )
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
- let window = NSWindow(contentViewController: viewController)
- defer { window.close() }
- window.setContentSize(NSSize(width: 360, height: 260))
- viewController.loadViewIfNeeded()
- viewController.view.layoutSubtreeIfNeeded()
-
- let sidebar = viewController.sidebarViewControllerForTesting
- #expect(sidebar.displayedSectionTitlesForTesting == ["workspace-alpha"])
- #expect(sidebar.displayedJobIDsForTesting(in: workspace) == [])
- #expect(sidebar.isShowingEmptyStateForTesting == false)
- let workspaceCellMinX = try #require(sidebar.workspaceCellMinXForTesting(workspace))
- let disclosureMaxX = try #require(sidebar.workspaceDisclosureMaxXForTesting(workspace))
- #expect(workspaceCellMinX >= disclosureMaxX - 0.5)
- }
-
- @Test func scrollingSidebarDoesNotFloatWorkspaceRows() throws {
- let primaryJobs = (0..<8).map { index in
- makeJob(
- id: "job-\(index)",
- cwd: "/tmp/workspace-alpha",
- status: .running,
- targetSummary: "Review \(index)"
- )
- }
- let secondaryJob = makeJob(
- id: "job-secondary",
- cwd: "/tmp/workspace-beta",
- status: .queued,
- targetSummary: "Queued review"
- )
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- content: makeSidebarContent(from: [secondaryJob] + primaryJobs)
- )
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
- let window = NSWindow(contentViewController: viewController)
- defer { window.close() }
- window.setContentSize(NSSize(width: 360, height: 220))
- viewController.loadViewIfNeeded()
- viewController.view.layoutSubtreeIfNeeded()
-
- let workspace = try #require(store.workspaces.first(where: { $0.cwd == "/tmp/workspace-alpha" }))
- let sidebar = viewController.sidebarViewControllerForTesting
- sidebar.scrollSidebarToOffsetForTesting(80)
-
- #expect(sidebar.workspaceRowIsFloatingForTesting(workspace) == false)
- }
-
- @Test func sidebarDoesNotAddBlankScrollWhenRowsFitVisibleArea() {
- let jobs = (0..<2).map { index in
- makeJob(
- id: "job-\(index)",
- cwd: "/tmp/workspace-alpha",
- status: .running,
- targetSummary: "Review \(index)"
- )
- }
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- content: makeSidebarContent(from: jobs)
- )
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
- let window = NSWindow(contentViewController: viewController)
- defer { window.close() }
- window.setContentSize(NSSize(width: 360, height: 320))
- viewController.loadViewIfNeeded()
- window.layoutIfNeeded()
- viewController.view.layoutSubtreeIfNeeded()
-
- let sidebar = viewController.sidebarViewControllerForTesting
-
- #expect(sidebar.sidebarOutlineContentHeightForTesting < sidebar.sidebarVisibleHeightForTesting)
- #expect(sidebar.sidebarMaximumVerticalScrollOffsetForTesting < 0.5)
- }
-
- @Test func sidebarTopRowIsFullyVisibleAtMinimumScrollOffset() {
- let jobs = (0..<12).map { index in
- makeJob(
- id: "job-\(index)",
- cwd: "/tmp/workspace-alpha",
- status: .running,
- targetSummary: "Review \(index)"
- )
- }
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- content: makeSidebarContent(from: jobs)
- )
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
- let window = NSWindow(contentViewController: viewController)
- defer { window.close() }
- window.setContentSize(NSSize(width: 360, height: 220))
- viewController.loadViewIfNeeded()
- viewController.attach(to: window)
- window.layoutIfNeeded()
- viewController.view.layoutSubtreeIfNeeded()
-
- let sidebar = viewController.sidebarViewControllerForTesting
- sidebar.scrollSidebarToOffsetForTesting(0)
-
- #expect(sidebar.sidebarFirstRowRectForTesting.minY >= sidebar.sidebarVisibleRectForTesting.minY - 0.5)
- #expect(sidebar.sidebarFirstRowRectForTesting.maxY <= sidebar.sidebarVisibleRectForTesting.maxY + 0.5)
- }
-
- @Test func sidebarBottomRowRemainsVisibleAtMaximumScrollOffset() {
- let jobs = (0..<12).map { index in
- makeJob(
- id: "job-\(index)",
- cwd: "/tmp/workspace-alpha",
- status: .running,
- targetSummary: "Review \(index)"
- )
- }
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- content: makeSidebarContent(from: jobs)
- )
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
- let window = NSWindow(contentViewController: viewController)
- defer { window.close() }
- window.setContentSize(NSSize(width: 360, height: 220))
- viewController.loadViewIfNeeded()
- viewController.attach(to: window)
- window.layoutIfNeeded()
- viewController.view.layoutSubtreeIfNeeded()
-
- let sidebar = viewController.sidebarViewControllerForTesting
- sidebar.scrollSidebarToOffsetForTesting(10_000)
-
- #expect(sidebar.sidebarLastRowRectForTesting.maxY <= sidebar.sidebarVisibleRectForTesting.maxY + 0.5)
- }
-
- @Test func nativeWorkspaceDisclosureKeepsModelAndOutlineExpansionInSync() async throws {
- let job = makeJob(
- id: "job-selected",
- cwd: "/tmp/workspace-alpha",
- status: .running,
- targetSummary: "Uncommitted changes",
- summary: "Review is still running.",
- logText: "Selected log\n"
- )
- let workspace = CodexReviewWorkspace(cwd: job.cwd)
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- workspaces: [workspace],
- jobs: [job]
- )
- let storedWorkspace = try #require(store.workspaces.first)
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
- let window = NSWindow(contentViewController: viewController)
- defer { window.close() }
- window.setContentSize(NSSize(width: 900, height: 600))
- viewController.loadViewIfNeeded()
- viewController.view.layoutSubtreeIfNeeded()
- let transport = viewController.transportViewControllerForTesting
- let sidebar = viewController.sidebarViewControllerForTesting
- sidebar.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
-
- sidebar.collapseWorkspaceInOutlineForTesting(storedWorkspace)
- try await waitForCondition {
- sidebar.workspaceIsExpandedForTesting(storedWorkspace) == false
- && sidebar.workspaceOutlineIsExpandedForTesting(storedWorkspace) == false
- }
-
- sidebar.expandWorkspaceInOutlineForTesting(storedWorkspace)
- try await waitForCondition {
- sidebar.workspaceIsExpandedForTesting(storedWorkspace)
- && sidebar.workspaceOutlineIsExpandedForTesting(storedWorkspace)
- && sidebar.selectedOutlineJobIDForTesting == job.id
- }
- }
-
- @Test func collapsedWorkspaceStaysCollapsedAcrossStoreReload() throws {
- let job = makeJob(
- id: "job-1",
- cwd: "/tmp/workspace-alpha",
- status: .running,
- targetSummary: "Uncommitted changes"
- )
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- content: makeSidebarContent(from: [job])
- )
- let workspace = try #require(store.workspaces.first(where: { $0.cwd == job.cwd }))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
- viewController.loadViewIfNeeded()
-
- let sidebar = viewController.sidebarViewControllerForTesting
- sidebar.toggleWorkspaceDisclosureForTesting(workspace)
- #expect(sidebar.workspaceIsExpandedForTesting(workspace) == false)
-
- let replacement = makeJob(
- id: "job-2",
- cwd: job.cwd,
- status: .succeeded,
- targetSummary: "Commit: abc123"
- )
- store.loadForTesting(
- serverState: .running,
- content: makeSidebarContent(from: [replacement])
- )
-
- let reloadedWorkspace = try #require(store.workspaces.first(where: { $0.cwd == job.cwd }))
- #expect(sidebar.workspaceIsExpandedForTesting(reloadedWorkspace) == false)
- }
-
- @Test func cancellingRunningJobFromSidebarMarksJobCancelled() async throws {
- let startedAt = Date(timeIntervalSince1970: 200)
- let job = makeJob(
- id: "job-running",
- cwd: "/tmp/workspace-alpha",
- startedAt: startedAt,
- status: .running,
- targetSummary: "Uncommitted changes",
- summary: "Running review."
- )
+ @Test func addAccountToolbarItemShowsProgressPresentation() async throws {
let store = CodexReviewStore.makePreviewStore()
+ let activeAccount = CodexReviewAccount(email: "first@example.com", planType: "pro")
store.loadForTesting(
serverState: .running,
- content: makeSidebarContent(from: [job])
- )
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
- viewController.loadViewIfNeeded()
-
- await viewController.sidebarViewControllerForTesting.cancelJobForTesting(job)
-
- #expect(job.core.lifecycle.status == .cancelled)
- #expect(job.core.output.summary == "Cancelled by user from Review Monitor.")
- #expect(job.core.lifecycle.errorMessage == "Cancelled by user from Review Monitor.")
- #expect(job.core.lifecycle.cancellation?.source == .userInterface)
- #expect(job.core.lifecycle.cancellation?.message == "Cancelled by user from Review Monitor.")
- #expect(job.core.lifecycle.startedAt == startedAt)
- #expect(job.core.lifecycle.endedAt != nil)
- }
-
- @Test func cancellationFailureUpdatesJobErrorState() async {
- let job = makeJob(
- id: "job-running",
- cwd: "/tmp/workspace-alpha",
- status: .running,
- targetSummary: "Uncommitted changes",
- summary: "Running review."
- )
- let store = CodexReviewStore.makeTestingStore(backend: FailingCancellationBackend())
- store.loadForTesting(
- serverState: .running,
- content: makeSidebarContent(from: [job])
+ authPhase: .signingIn(
+ .init(
+ title: "Sign in with ChatGPT",
+ detail: "Open the browser to continue."
+ )
+ ),
+ account: activeAccount,
+ persistedAccounts: [activeAccount]
)
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
- viewController.loadViewIfNeeded()
-
- await viewController.sidebarViewControllerForTesting.cancelJobForTesting(job)
-
- #expect(job.core.lifecycle.status == .running)
- #expect(job.core.output.summary == "Failed to cancel review: Cancellation failed.")
- #expect(job.core.lifecycle.errorMessage == "Cancellation failed.")
- #expect(job.core.lifecycle.endedAt == nil)
- }
- @Test func sidebarContextMenuPresentationRestoresResponderStateAfterClosing() {
- let job = makeJob(
- id: "job-running",
- cwd: "/tmp/workspace-alpha",
- status: .running,
- targetSummary: "Uncommitted changes",
- summary: "Running review."
- )
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- content: makeSidebarContent(from: [job])
- )
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ let uiState = ReviewMonitorUIState(auth: store.auth)
+ uiState.sidebarSelection = .account
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(store: store, uiState: uiState)
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 900, height: 600))
- viewController.loadViewIfNeeded()
- let sidebar = viewController.sidebarViewControllerForTesting
- sidebar.focusSidebarForTesting()
-
- #expect(sidebar.sidebarHasFirstResponderForTesting)
- #expect(sidebar.acceptsFirstResponderForTesting)
- #expect(sidebar.hasTemporaryContextMenuForTesting == false)
-
- var presentedTitles: [String] = []
- sidebar.presentContextMenuForTesting(for: job) { menu in
- presentedTitles = menu.items.map(\.title)
- #expect(sidebar.isPresentingContextMenuForTesting)
- #expect(sidebar.acceptsFirstResponderForTesting == false)
- #expect(sidebar.sidebarHasFirstResponderForTesting == false)
- #expect(sidebar.hasTemporaryContextMenuForTesting)
- }
+ viewController.attach(to: window)
+ let sidebarItem = try #require(viewController.splitViewItems.first)
+ sidebarItem.isCollapsed = false
+ window.layoutIfNeeded()
+ try await waitForAddAccountToolbarItemHidden(viewController, false)
- #expect(presentedTitles == ["Cancel"])
- #expect(sidebar.isPresentingContextMenuForTesting == false)
- #expect(sidebar.acceptsFirstResponderForTesting)
- #expect(sidebar.sidebarHasFirstResponderForTesting)
- #expect(sidebar.hasTemporaryContextMenuForTesting == false)
+ #expect(viewController.addAccountToolbarItemModeForTesting == .progress)
}
- @Test func accountContextMenuPresentationRestoresResponderStateAfterClosing() throws {
- let activeAccount = CodexAccount(email: "active@example.com", planType: "pro")
- let otherAccount = CodexAccount(email: "other@example.com", planType: "plus")
+ @Test func addAccountToolbarItemDoesNotStickInProgressModeWhenAuthenticationEndsImmediately() async throws {
let store = CodexReviewStore.makePreviewStore()
+ let activeAccount = CodexReviewAccount(email: "first@example.com", planType: "pro")
store.loadForTesting(
serverState: .running,
+ authPhase: .signedOut,
account: activeAccount,
- persistedAccounts: [activeAccount, otherAccount],
- workspaces: []
+ persistedAccounts: [activeAccount]
)
+
let uiState = ReviewMonitorUIState(auth: store.auth)
uiState.sidebarSelection = .account
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(store: store, uiState: uiState)
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 900, height: 600))
- viewController.loadViewIfNeeded()
- let accountsViewController = viewController
- .sidebarViewControllerForTesting
- .accountsViewControllerForTesting
- let displayedOtherAccount = try #require(
- store.auth.persistedAccounts.first { $0.email == "other@example.com" }
- )
- accountsViewController.focusAccountListForTesting()
+ viewController.attach(to: window)
+ let sidebarItem = try #require(viewController.splitViewItems.first)
+ sidebarItem.isCollapsed = false
+ window.layoutIfNeeded()
+ try await waitForAddAccountToolbarItemHidden(viewController, false)
- #expect(accountsViewController.accountListHasFirstResponderForTesting)
- #expect(accountsViewController.acceptsFirstResponderForTesting)
- #expect(accountsViewController.hasTemporaryContextMenuForTesting == false)
+ #expect(viewController.addAccountToolbarItemModeForTesting == .add)
- var presentedTitles: [String] = []
- var presentedHostingMenu = false
- accountsViewController.presentContextMenuForTesting(for: displayedOtherAccount) { menu in
- presentedTitles = menu.items.map(\.title).filter { $0.isEmpty == false }
- presentedHostingMenu = menu is NSHostingMenu
- #expect(accountsViewController.isPresentingContextMenuForTesting)
- #expect(accountsViewController.acceptsFirstResponderForTesting == false)
- #expect(accountsViewController.accountListHasFirstResponderForTesting == false)
- #expect(accountsViewController.hasTemporaryContextMenuForTesting)
- }
+ store.auth.updatePhase(
+ .signingIn(
+ .init(
+ title: "Sign in with ChatGPT",
+ detail: "Open the browser to continue."
+ )
+ )
+ )
+ store.auth.updatePhase(.signedOut)
- #expect(presentedTitles == [
- "other@example.com",
- "Switch",
- "Refresh",
- "Sign Out",
- ])
- #expect(presentedHostingMenu)
- #expect(accountsViewController.isPresentingContextMenuForTesting == false)
- #expect(accountsViewController.acceptsFirstResponderForTesting)
- #expect(accountsViewController.accountListHasFirstResponderForTesting)
- #expect(accountsViewController.hasTemporaryContextMenuForTesting == false)
+ try await waitForAddAccountToolbarMode(viewController, .add)
+ #expect(viewController.addAccountToolbarItemModeForTesting == .add)
}
- @Test func accountOutlineRowsRejectUserSelection() async throws {
- let activeAccount = CodexAccount(email: "active@example.com", planType: "pro")
- let otherAccount = CodexAccount(email: "other@example.com", planType: "plus")
- let backend = AuthActionBackend()
- let store = makeStore(backend: backend)
+ @Test func addAccountToolbarItemProvidesOverflowMenuFallback() async throws {
+ let store = CodexReviewStore.makePreviewStore()
+ let activeAccount = CodexReviewAccount(email: "first@example.com", planType: "pro")
store.loadForTesting(
serverState: .running,
+ authPhase: .signedOut,
account: activeAccount,
- persistedAccounts: [activeAccount, otherAccount],
- workspaces: []
+ persistedAccounts: [activeAccount]
)
+
let uiState = ReviewMonitorUIState(auth: store.auth)
uiState.sidebarSelection = .account
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(store: store, uiState: uiState)
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 900, height: 600))
- viewController.loadViewIfNeeded()
- let accountsViewController = viewController
- .sidebarViewControllerForTesting
- .accountsViewControllerForTesting
- let displayedOtherAccount = try #require(
- store.auth.persistedAccounts.first { $0.email == "other@example.com" }
- )
+ viewController.attach(to: window)
+ let sidebarItem = try #require(viewController.splitViewItems.first)
+ sidebarItem.isCollapsed = false
+ window.layoutIfNeeded()
+ try await waitForAddAccountToolbarItemHidden(viewController, false)
+ try await waitForAddAccountToolbarMode(viewController, .add)
- try await waitForObservedValue(
- from: accountsViewController.accountListObservationForTesting,
- true
- ) {
- accountsViewController.selectedAccountEmailForTesting == "active@example.com"
- }
+ #expect(viewController.addAccountToolbarMenuTitleForTesting == "Add Account")
- #expect(accountsViewController.selectedAccountEmailForTesting == "active@example.com")
- #expect(accountsViewController.allowsUserSelectionForTesting(displayedOtherAccount) == false)
- for _ in 0..<10 {
- await Task.yield()
- }
+ store.auth.updatePhase(
+ .signingIn(
+ .init(
+ title: "Sign in with ChatGPT",
+ detail: "Open the browser to continue."
+ )
+ )
+ )
- #expect(accountsViewController.selectedAccountEmailForTesting == "active@example.com")
- #expect(store.auth.selectedAccount?.email == "active@example.com")
- #expect(backend.switchAccountCallCount() == 0)
+ try await waitForAddAccountToolbarMode(viewController, .progress)
+ #expect(viewController.addAccountToolbarMenuTitleForTesting == "Cancel Sign-In")
}
- @Test func accountDragUsesClickedRowWithoutChangingAuthSelection() async throws {
- let activeAccount = CodexAccount(email: "active@example.com", planType: "pro")
- let otherAccount = CodexAccount(email: "other@example.com", planType: "plus")
+ @Test func addAccountToolbarItemStaysVisibleDuringAuthenticationOutsideAccountSidebar() async throws {
let store = CodexReviewStore.makePreviewStore()
+ let activeAccount = CodexReviewAccount(email: "first@example.com", planType: "pro")
store.loadForTesting(
serverState: .running,
+ authPhase: .signingIn(
+ .init(
+ title: "Sign in with ChatGPT",
+ detail: "Open the browser to continue."
+ )
+ ),
account: activeAccount,
- persistedAccounts: [activeAccount, otherAccount],
- workspaces: []
+ persistedAccounts: [activeAccount]
)
- let uiState = ReviewMonitorUIState(auth: store.auth)
- uiState.sidebarSelection = .account
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
- viewController.loadViewIfNeeded()
- let accountsViewController = viewController
- .sidebarViewControllerForTesting
- .accountsViewControllerForTesting
- let displayedOtherAccount = try #require(
- store.auth.persistedAccounts.first { $0.email == "other@example.com" }
- )
- try await waitForObservedValue(
- from: accountsViewController.accountListObservationForTesting,
- true
- ) {
- accountsViewController.selectedAccountEmailForTesting == "active@example.com"
- }
+ let uiState = ReviewMonitorUIState(auth: store.auth)
+ uiState.sidebarSelection = .workspace
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(store: store, uiState: uiState)
+ let window = NSWindow(contentViewController: viewController)
+ defer { window.close() }
+ window.setContentSize(NSSize(width: 900, height: 600))
- #expect(accountsViewController.dragPasteboardAccountKeyForTesting(displayedOtherAccount) == displayedOtherAccount.accountKey)
- #expect(accountsViewController.selectedAccountEmailForTesting == "active@example.com")
- #expect(store.auth.selectedAccount?.email == "active@example.com")
+ viewController.attach(to: window)
+ let sidebarItem = try #require(viewController.splitViewItems.first)
+ sidebarItem.isCollapsed = true
+ window.layoutIfNeeded()
+
+ #expect(viewController.addAccountToolbarItemIsHiddenForTesting == false)
+ #expect(viewController.addAccountToolbarItemModeForTesting == .progress)
}
- @Test func accountBlankClickKeepsAuthenticatedSelection() async throws {
- let activeAccount = CodexAccount(email: "active@example.com", planType: "pro")
- let otherAccount = CodexAccount(email: "other@example.com", planType: "plus")
+ @Test func addAccountToolbarItemRehidesAfterAuthenticationEndsOutsideAccountSidebar() async throws {
let store = CodexReviewStore.makePreviewStore()
+ let activeAccount = CodexReviewAccount(email: "first@example.com", planType: "pro")
store.loadForTesting(
serverState: .running,
+ authPhase: .signingIn(
+ .init(
+ title: "Sign in with ChatGPT",
+ detail: "Open the browser to continue."
+ )
+ ),
account: activeAccount,
- persistedAccounts: [activeAccount, otherAccount],
- workspaces: []
+ persistedAccounts: [activeAccount]
)
+
let uiState = ReviewMonitorUIState(auth: store.auth)
- uiState.sidebarSelection = .account
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
+ uiState.sidebarSelection = .workspace
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(store: store, uiState: uiState)
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 900, height: 600))
- viewController.loadViewIfNeeded()
- viewController.view.layoutSubtreeIfNeeded()
- let accountsViewController = viewController
- .sidebarViewControllerForTesting
- .accountsViewControllerForTesting
- try await waitForObservedValue(
- from: accountsViewController.accountListObservationForTesting,
- true
- ) {
- accountsViewController.selectedAccountEmailForTesting == "active@example.com"
- }
+ viewController.attach(to: window)
+ let sidebarItem = try #require(viewController.splitViewItems.first)
+ sidebarItem.isCollapsed = true
+ window.layoutIfNeeded()
- accountsViewController.clickBlankAreaForTesting()
- for _ in 0..<10 {
- await Task.yield()
- }
+ #expect(viewController.addAccountToolbarItemIsHiddenForTesting == false)
- #expect(accountsViewController.selectedAccountEmailForTesting == "active@example.com")
- #expect(store.auth.selectedAccount?.email == "active@example.com")
+ store.auth.updatePhase(.signedOut)
+
+ try await waitForCondition {
+ viewController.addAccountToolbarItemIsHiddenForTesting
+ }
+ #expect(viewController.addAccountToolbarItemIsHiddenForTesting)
}
- @Test func accountSelectionChangeKeepsDisplayedAccounts() async throws {
- let activeAccount = CodexAccount(email: "active@example.com", planType: "pro")
- let otherAccount = CodexAccount(email: "other@example.com", planType: "plus")
+ @Test func accountSidebarUsesOutlineViewRows() throws {
+ let activeAccount = CodexReviewAccount(email: "active@example.com", planType: "pro")
+ let otherAccount = CodexReviewAccount(email: "other@example.com", planType: "plus")
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
account: activeAccount,
- persistedAccounts: [activeAccount, otherAccount],
- workspaces: []
+ persistedAccounts: [activeAccount, otherAccount]
)
let uiState = ReviewMonitorUIState(auth: store.auth)
uiState.sidebarSelection = .account
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
- let window = NSWindow(contentViewController: viewController)
- defer { window.close() }
- window.setContentSize(NSSize(width: 900, height: 600))
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(store: store, uiState: uiState)
viewController.loadViewIfNeeded()
let accountsViewController = viewController
.sidebarViewControllerForTesting
.accountsViewControllerForTesting
- let displayedOtherAccount = try #require(
- store.auth.persistedAccounts.first { $0.email == "other@example.com" }
+ let displayedActiveAccount = try #require(
+ store.auth.persistedAccounts.first { $0.email == "active@example.com" }
)
- try await waitForObservedValue(
- from: accountsViewController.accountListObservationForTesting,
- true
- ) {
- accountsViewController.selectedAccountEmailForTesting == "active@example.com"
- }
- let displayedEmails = accountsViewController.displayedAccountEmailsForTesting
- let fullReloadCountBeforeSelectionChange = accountsViewController.accountFullReloadCountForTesting
- let incrementalMembershipChangeCountBeforeSelectionChange = accountsViewController
- .accountIncrementalMembershipChangeCountForTesting
- let incrementalMoveCountBeforeSelectionChange = accountsViewController.accountIncrementalMoveCountForTesting
-
- store.auth.selectPersistedAccount(displayedOtherAccount.accountKey)
- try await waitForObservedValue(
- from: accountsViewController.accountListObservationForTesting,
- true
- ) {
- accountsViewController.selectedAccountEmailForTesting == "other@example.com"
- }
- #expect(accountsViewController.selectedAccountEmailForTesting == "other@example.com")
- #expect(accountsViewController.displayedAccountEmailsForTesting == displayedEmails)
- #expect(accountsViewController.accountFullReloadCountForTesting == fullReloadCountBeforeSelectionChange)
+ #expect(accountsViewController.accountListUsesOutlineViewForTesting)
#expect(
- accountsViewController.accountIncrementalMembershipChangeCountForTesting ==
- incrementalMembershipChangeCountBeforeSelectionChange
- )
- #expect(accountsViewController.accountIncrementalMoveCountForTesting == incrementalMoveCountBeforeSelectionChange)
+ accountsViewController.displayedAccountEmailsForTesting == [
+ "active@example.com",
+ "other@example.com",
+ ])
+ #expect(accountsViewController.accountRowUsesReviewMonitorAccountCellViewForTesting(displayedActiveAccount))
+ #expect(accountsViewController.accountRowUsesSwiftUIRowViewForTesting(displayedActiveAccount))
}
- @Test func accountContentUpdateDoesNotReloadOutlineTopology() async throws {
- let activeAccount = CodexAccount(email: "active@example.com", planType: "pro")
+ @Test func accountDropReordersToDisplayedGapForDownwardMove() async throws {
+ let firstAccount = CodexReviewAccount(email: "first@example.com", planType: "pro")
+ let secondAccount = CodexReviewAccount(email: "second@example.com", planType: "plus")
+ let thirdAccount = CodexReviewAccount(email: "third@example.com", planType: "team")
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
- account: activeAccount,
- persistedAccounts: [activeAccount],
- workspaces: []
+ account: firstAccount,
+ persistedAccounts: [firstAccount, secondAccount, thirdAccount]
)
let uiState = ReviewMonitorUIState(auth: store.auth)
uiState.sidebarSelection = .account
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(store: store, uiState: uiState)
viewController.loadViewIfNeeded()
let accountsViewController = viewController
.sidebarViewControllerForTesting
.accountsViewControllerForTesting
- let displayedActiveAccount = try #require(store.auth.persistedAccounts.first)
- let fullReloadCountBeforeUpdate = accountsViewController.accountFullReloadCountForTesting
- let incrementalMembershipChangeCountBeforeUpdate = accountsViewController
+ let displayedFirstAccount = try #require(
+ store.auth.persistedAccounts.first { $0.email == "first@example.com" }
+ )
+ let fullReloadCountBeforeDrop = accountsViewController.accountFullReloadCountForTesting
+ let incrementalMembershipChangeCountBeforeDrop = accountsViewController
.accountIncrementalMembershipChangeCountForTesting
- let incrementalMoveCountBeforeUpdate = accountsViewController.accountIncrementalMoveCountForTesting
+ let incrementalMoveCountBeforeDrop = accountsViewController.accountIncrementalMoveCountForTesting
- let updatedAccount = CodexAccount(email: "active@example.com", planType: "team")
- store.auth.applyPersistedAccountStates([savedAccountPayload(from: updatedAccount)])
- for _ in 0..<10 {
- await Task.yield()
+ #expect(
+ await accountsViewController.performAccountDropForTesting(
+ displayedFirstAccount,
+ proposedChildIndex: 2
+ ))
+ #expect(
+ store.auth.persistedAccounts.map(\.email) == [
+ "second@example.com",
+ "first@example.com",
+ "third@example.com",
+ ])
+ try await waitForObservedValue(
+ from: accountsViewController.accountListObservationForTesting,
+ [
+ "second@example.com",
+ "first@example.com",
+ "third@example.com",
+ ]
+ ) {
+ accountsViewController.displayedAccountEmailsForTesting
}
-
- #expect(store.auth.persistedAccounts.first === displayedActiveAccount)
- #expect(store.auth.persistedAccounts.first?.planType == "team")
- #expect(accountsViewController.displayedAccountEmailsForTesting == ["active@example.com"])
- #expect(accountsViewController.accountFullReloadCountForTesting == fullReloadCountBeforeUpdate)
+ #expect(accountsViewController.accountFullReloadCountForTesting == fullReloadCountBeforeDrop)
#expect(
- accountsViewController.accountIncrementalMembershipChangeCountForTesting ==
- incrementalMembershipChangeCountBeforeUpdate
+ accountsViewController.accountIncrementalMembershipChangeCountForTesting
+ == incrementalMembershipChangeCountBeforeDrop
)
- #expect(accountsViewController.accountIncrementalMoveCountForTesting == incrementalMoveCountBeforeUpdate)
+ #expect(accountsViewController.accountIncrementalMoveCountForTesting == incrementalMoveCountBeforeDrop + 1)
}
- @Test func accountListTracksDetachedCurrentSessionMembership() async throws {
- let savedAccount = CodexAccount(email: "saved@example.com", planType: "pro")
+ @Test func accountDropBeforeDetachedCurrentSessionMovesToLastSavedPosition() async throws {
+ let firstAccount = CodexReviewAccount(email: "first@example.com", planType: "pro")
+ let secondAccount = CodexReviewAccount(email: "second@example.com", planType: "plus")
+ let detachedAccount = CodexReviewAccount(email: "detached@example.com", planType: "team")
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
- account: savedAccount,
- persistedAccounts: [savedAccount],
- workspaces: []
+ account: detachedAccount,
+ persistedAccounts: [firstAccount, secondAccount]
)
let uiState = ReviewMonitorUIState(auth: store.auth)
uiState.sidebarSelection = .account
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
- let window = NSWindow(contentViewController: viewController)
- defer { window.close() }
- window.setContentSize(NSSize(width: 900, height: 600))
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(store: store, uiState: uiState)
viewController.loadViewIfNeeded()
let accountsViewController = viewController
.sidebarViewControllerForTesting
.accountsViewControllerForTesting
- #expect(accountsViewController.displayedAccountEmailsForTesting == ["saved@example.com"])
- let fullReloadCountBeforeMembershipChanges = accountsViewController.accountFullReloadCountForTesting
- let incrementalMembershipChangeCountBeforeMembershipChanges = accountsViewController
- .accountIncrementalMembershipChangeCountForTesting
- let incrementalMoveCountBeforeMembershipChanges = accountsViewController.accountIncrementalMoveCountForTesting
+ let displayedFirstAccount = try #require(
+ store.auth.persistedAccounts.first { $0.email == "first@example.com" }
+ )
- store.auth.updateCurrentAccount(CodexAccount(email: "detached@example.com", planType: "pro"))
+ #expect(
+ accountsViewController.displayedAccountEmailsForTesting == [
+ "first@example.com",
+ "second@example.com",
+ "detached@example.com",
+ ])
+ #expect(
+ await accountsViewController.performAccountDropForTesting(
+ displayedFirstAccount,
+ proposedItem: detachedAccount,
+ proposedChildIndex: NSOutlineViewDropOnItemIndex
+ ))
+ #expect(
+ store.auth.persistedAccounts.map(\.email) == [
+ "second@example.com",
+ "first@example.com",
+ ])
try await waitForObservedValue(
from: accountsViewController.accountListObservationForTesting,
[
- "saved@example.com",
+ "second@example.com",
+ "first@example.com",
"detached@example.com",
]
) {
accountsViewController.displayedAccountEmailsForTesting
}
-
- #expect(accountsViewController.displayedAccountEmailsForTesting == [
- "saved@example.com",
- "detached@example.com",
- ])
- #expect(accountsViewController.selectedAccountEmailForTesting == "detached@example.com")
- #expect(accountsViewController.accountFullReloadCountForTesting == fullReloadCountBeforeMembershipChanges)
#expect(
- accountsViewController.accountIncrementalMembershipChangeCountForTesting ==
- incrementalMembershipChangeCountBeforeMembershipChanges + 1
+ accountsViewController.displayedAccountEmailsForTesting == [
+ "second@example.com",
+ "first@example.com",
+ "detached@example.com",
+ ])
+ }
+
+ @Test func reviewChatCellViewUpdatesNativeOwnerStateWhenConfiguredWithNewChat() async throws {
+ let placeholderChat = try await reviewChatCellTestChat(
+ id: "chat-placeholder",
+ title: "Queued review",
+ workspaceCWD: "/tmp/placeholder"
+ )
+ let loadedChat = try await reviewChatCellTestChat(
+ id: "chat-loaded",
+ title: "Uncommitted changes",
+ workspaceCWD: "/tmp/loaded"
)
- #expect(accountsViewController.accountIncrementalMoveCountForTesting == incrementalMoveCountBeforeMembershipChanges)
- store.auth.selectPersistedAccount(savedAccount.accountKey)
- try await waitForObservedValue(
- from: accountsViewController.accountListObservationForTesting,
- ["saved@example.com"]
- ) {
- accountsViewController.displayedAccountEmailsForTesting
- }
+ let cellView = makeReviewMonitorReviewChatCellViewForTesting(chat: placeholderChat)
+ let initialObjectChat = try #require(cellView.objectValue as? CodexChat)
- #expect(accountsViewController.displayedAccountEmailsForTesting == ["saved@example.com"])
- #expect(accountsViewController.selectedAccountEmailForTesting == "saved@example.com")
- #expect(accountsViewController.accountFullReloadCountForTesting == fullReloadCountBeforeMembershipChanges)
- #expect(
- accountsViewController.accountIncrementalMembershipChangeCountForTesting ==
- incrementalMembershipChangeCountBeforeMembershipChanges + 2
- )
- #expect(accountsViewController.accountIncrementalMoveCountForTesting == incrementalMoveCountBeforeMembershipChanges)
+ #expect(initialObjectChat.id == placeholderChat.id)
+ #expect(cellView.toolTip == (placeholderChat.workspace?.url.path ?? placeholderChat.preview ?? placeholderChat.title))
+
+ configureReviewMonitorReviewChatCellViewForTesting(cellView, chat: loadedChat)
+
+ let objectChat = try #require(cellView.objectValue as? CodexChat)
+ #expect(objectChat.id == loadedChat.id)
+ #expect(cellView.toolTip == (loadedChat.workspace?.url.path ?? loadedChat.preview ?? loadedChat.title))
}
- @Test func accountActionAlertRestoresSelectionToAuthenticatedAccount() async throws {
- let activeAccount = CodexAccount(email: "active@example.com", planType: "pro")
- let otherAccount = CodexAccount(email: "other@example.com", planType: "plus")
+ @Test func accountContextMenuPresentationRestoresResponderStateAfterClosing() throws {
+ let activeAccount = CodexReviewAccount(email: "active@example.com", planType: "pro")
+ let otherAccount = CodexReviewAccount(email: "other@example.com", planType: "plus")
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
account: activeAccount,
- persistedAccounts: [activeAccount, otherAccount],
- workspaces: []
+ persistedAccounts: [activeAccount, otherAccount]
)
let uiState = ReviewMonitorUIState(auth: store.auth)
uiState.sidebarSelection = .account
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(store: store, uiState: uiState)
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 900, height: 600))
@@ -2223,978 +395,440 @@ struct ReviewUITests {
let displayedOtherAccount = try #require(
store.auth.persistedAccounts.first { $0.email == "other@example.com" }
)
+ accountsViewController.focusAccountListForTesting()
- accountsViewController.selectAccountRowForTesting(displayedOtherAccount)
- #expect(accountsViewController.selectedAccountEmailForTesting == "other@example.com")
-
- store.auth.presentAccountActionAlert(
- title: "Failed to Switch Accounts",
- message: "Request failed."
- )
- try await waitForObservedValue(
- from: accountsViewController.accountPromptObservationForTesting,
- true
- ) {
- accountsViewController.selectedAccountEmailForTesting == "active@example.com"
- }
-
- #expect(accountsViewController.selectedAccountEmailForTesting == "active@example.com")
- }
-
- @Test func jobsPresentOnInitialLoadStayUnselected() {
- let activeJob = makeJob(status: .running, targetSummary: "Uncommitted changes")
- let recentJob = makeJob(status: .succeeded, targetSummary: "Commit: abc123")
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- content: makeSidebarContent(from: [activeJob, recentJob])
- )
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
- viewController.loadViewIfNeeded()
-
- #expect(viewController.sidebarViewControllerForTesting.selectedJobForTesting == nil)
- #expect(viewController.contentPaneViewControllerForTesting.isShowingEmptyStateForTesting)
- #expect(viewController.contentPaneViewControllerForTesting.displayedTitleForTesting == nil)
- }
-
- @Test func selectingJobUpdatesDetailPane() async throws {
- let activeJob = makeJob(status: .running, targetSummary: "Uncommitted changes", logText: "Running review\n")
- let recentJob = makeJob(
- status: .succeeded,
- targetSummary: "Commit: abc123",
- summary: "MCP server codex_review ready.",
- logText: "Findings ready\n"
- )
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- content: makeSidebarContent(from: [activeJob, recentJob])
- )
- let backend = makeWindowHarness(store: store)
- let viewController = backend.viewController
- let window = backend.window
- defer { window.close() }
- let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(recentJob)
-
- let selectedSnapshot = try await awaitTransportRender(transport)
- #expect(
- selectedSnapshot == .init(
- title: nil,
- summary: nil,
- log: recentJob.logText,
- isShowingEmptyState: false
- )
- )
- #expect(window.title == recentJob.targetSummary)
- #expect(window.subtitle == recentJob.cwd)
- #expect(transport.logUsesFindBarForTesting)
- #expect(transport.logIsIncrementalSearchingEnabledForTesting)
- #expect(transport.logFindBarVisibleForTesting == false)
-
- let findItem = textFinderMenuItemForTesting(.showFindInterface)
- #expect(viewController.validateUserInterfaceItem(findItem))
- viewController.performTextFinderAction(findItem)
- #expect(transport.logFindBarVisibleForTesting)
-
- recentJob.updateStateForTesting(targetSummary: "Commit: def456")
- try await waitForCondition {
- window.title == "Commit: def456"
- }
- #expect(window.title == "Commit: def456")
- #expect(window.subtitle == recentJob.cwd)
- activeJob.updateStateForTesting(summary: "Old selection should not render.")
- activeJob.replaceLogEntries([.init(kind: .agentMessage, text: "Old selection log")])
- recentJob.appendLogEntry(.init(kind: .progress, text: "Current selection log after stale mutation"))
-
- let updatedSnapshot = try await awaitTransportRender(transport) { snapshot in
- snapshot.log.contains("Current selection log after stale mutation")
- }
- #expect(updatedSnapshot.log.contains("Old selection log") == false)
- #expect(transport.displayedLogForTesting.contains("Old selection log") == false)
- }
-
- @Test func selectingWorkspaceShowsStructuredFindings() async throws {
- let workspaceCWD = "/tmp/workspace-alpha"
- let firstJob = makeJob(
- id: "job-first-findings",
- cwd: workspaceCWD,
- status: .succeeded,
- targetSummary: "Commit: abc123",
- reviewResult: .init(
- state: .hasFindings,
- findingCount: 2,
- findings: [
- .init(
- title: "[P0] Stop stale undo commands",
- body: "Queued undo work must be cancelled before clearing history.",
- priority: 0,
- location: nil,
- rawText: ""
- ),
- .init(
- title: "[P1] Preserve selection identity",
- body: "The sidebar should resolve the selected workspace by cwd after reload.",
- priority: 1,
- location: .init(
- path: "\(workspaceCWD)/Sources/Sidebar.swift",
- startLine: 10,
- endLine: 12
- ),
- rawText: ""
- )
- ],
- source: .parsedFinalReviewText
- )
- )
- let secondJob = makeJob(
- id: "job-second-findings",
- cwd: workspaceCWD,
- status: .succeeded,
- targetSummary: "Branch: workspace-detail",
- reviewResult: .init(
- state: .hasFindings,
- findingCount: 1,
- findings: [
- .init(
- title: "[P2] Render workspace findings",
- body: "The detail pane should aggregate structured findings without parsing logs.",
- priority: 2,
- location: .init(
- path: "/tmp/workspace-alpha-other/Other.swift",
- startLine: 5,
- endLine: 5
- ),
- rawText: ""
- )
- ],
- source: .parsedFinalReviewText
- )
- )
- let workspace = CodexReviewWorkspace(cwd: workspaceCWD)
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, workspaces: [workspace], jobs: [firstJob, secondJob])
- let backend = makeWindowHarness(store: store)
- let viewController = backend.viewController
- let window = backend.window
- defer { window.close() }
- let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectWorkspaceForTesting(workspace)
-
- _ = try await awaitTransportRender(transport)
- #expect(viewController.sidebarViewControllerForTesting.selectedWorkspaceSectionForTesting?.workspaceCWDs == [workspaceCWD])
- #expect(viewController.sidebarViewControllerForTesting.selectedJobForTesting == nil)
- #expect(transport.workspaceFindingsTextIsSelectableForTesting)
- #expect(transport.workspaceFindingsTextIsEditableForTesting == false)
- #expect(transport.workspaceFindingsUsesFindBarForTesting)
- #expect(transport.workspaceFindingsIsIncrementalSearchingEnabledForTesting)
- #expect(transport.workspaceFindingsFindBarVisibleForTesting == false)
- #expect(transport.workspaceFindingsThreadBackgroundRangeCountForTesting == 2)
- #expect(transport.workspaceFindingsAccessibilityValueForTesting?.isEmpty == false)
- #expect(transport.workspaceFindingSnapshotForTesting.text.isEmpty == false)
- #expect(transport.workspaceFindingSnapshotForTesting.isShowingNoFindingsState == false)
- #expect(transport.workspaceFindingSnapshotForTesting.isShowingFindingsList)
- try await waitForCondition {
- window.title == workspace.displayTitle &&
- window.subtitle == workspace.cwd
+ #expect(accountsViewController.accountListHasFirstResponderForTesting)
+ #expect(accountsViewController.acceptsFirstResponderForTesting)
+ #expect(accountsViewController.hasTemporaryContextMenuForTesting == false)
+
+ var presentedTitles: [String] = []
+ var presentedHostingMenu = false
+ accountsViewController.presentContextMenuForTesting(for: displayedOtherAccount) { menu in
+ presentedTitles = menu.items.map(\.title).filter { $0.isEmpty == false }
+ presentedHostingMenu = menu is NSHostingMenu
+ #expect(accountsViewController.isPresentingContextMenuForTesting)
+ #expect(accountsViewController.acceptsFirstResponderForTesting == false)
+ #expect(accountsViewController.accountListHasFirstResponderForTesting == false)
+ #expect(accountsViewController.hasTemporaryContextMenuForTesting)
}
- #expect(window.title == workspace.displayTitle)
- #expect(window.subtitle == workspace.cwd)
- let findItem = textFinderMenuItemForTesting(.showFindInterface)
- #expect(viewController.validateUserInterfaceItem(findItem))
- viewController.performTextFinderAction(findItem)
- #expect(transport.workspaceFindingsFindBarVisibleForTesting)
+ #expect(
+ presentedTitles == [
+ "other@example.com",
+ "Switch",
+ "Refresh",
+ "Sign Out",
+ ])
+ #expect(presentedHostingMenu)
+ #expect(accountsViewController.isPresentingContextMenuForTesting == false)
+ #expect(accountsViewController.acceptsFirstResponderForTesting)
+ #expect(accountsViewController.accountListHasFirstResponderForTesting)
+ #expect(accountsViewController.hasTemporaryContextMenuForTesting == false)
}
- @Test func sidebarGroupsLinkedWorktreeWorkspacesByCommonGitDirectory() async throws {
- let fixture = try makeLinkedWorktreeFixtureForTesting(repositoryName: "CodexReviewKit")
- defer {
- try? FileManager.default.removeItem(at: fixture.rootURL)
- }
-
- let firstWorkspace = CodexReviewWorkspace(cwd: fixture.firstWorktreeURL.path)
- let secondWorkspace = CodexReviewWorkspace(cwd: fixture.secondWorktreeURL.path)
- let firstJob = makeJob(
- id: "job-first-worktree",
- cwd: firstWorkspace.cwd,
- status: .succeeded,
- targetSummary: "Base branch: refs/pr-fix/base/40",
- reviewResult: .init(
- state: .hasFindings,
- findingCount: 1,
- findings: [
- .init(
- title: "[P1] Keep first worktree visible",
- body: "The grouped sidebar should still render jobs from the first worktree.",
- priority: 1,
- location: .init(
- path: "\(firstWorkspace.cwd)/Sources/First.swift",
- startLine: 1,
- endLine: 1
- ),
- rawText: ""
- )
- ],
- source: .parsedFinalReviewText
- )
- )
- let secondJob = makeJob(
- id: "job-second-worktree",
- cwd: secondWorkspace.cwd,
- status: .succeeded,
- targetSummary: "Base branch: refs/pr-fix/base/41",
- reviewResult: .init(
- state: .hasFindings,
- findingCount: 1,
- findings: [
- .init(
- title: "[P2] Keep second worktree visible",
- body: "The grouped sidebar should also render jobs from the second worktree.",
- priority: 2,
- location: .init(
- path: "\(secondWorkspace.cwd)/Sources/Second.swift",
- startLine: 2,
- endLine: 2
- ),
- rawText: ""
- )
- ],
- source: .parsedFinalReviewText
- )
- )
- let store = CodexReviewStore.makePreviewStore()
+ @Test func accountOutlineRowsRejectUserSelection() async throws {
+ let activeAccount = CodexReviewAccount(email: "active@example.com", planType: "pro")
+ let otherAccount = CodexReviewAccount(email: "other@example.com", planType: "plus")
+ let backend = AuthActionBackend()
+ let store = makeStore(backend: backend)
store.loadForTesting(
serverState: .running,
- workspaces: [firstWorkspace, secondWorkspace],
- jobs: [firstJob, secondJob]
+ account: activeAccount,
+ persistedAccounts: [activeAccount, otherAccount]
)
- let backend = makeWindowHarness(store: store)
- let viewController = backend.viewController
- let window = backend.window
+ let uiState = ReviewMonitorUIState(auth: store.auth)
+ uiState.sidebarSelection = .account
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(store: store, uiState: uiState)
+ let window = NSWindow(contentViewController: viewController)
defer { window.close() }
- let sidebar = viewController.sidebarViewControllerForTesting
- let transport = viewController.transportViewControllerForTesting
-
- #expect(sidebar.displayedSectionTitlesForTesting == ["CodexReviewKit"])
- #expect(sidebar.displayedJobIDsForTesting(in: firstWorkspace) == ["job-first-worktree"])
- #expect(sidebar.displayedJobIDsForTesting(in: secondWorkspace) == ["job-second-worktree"])
+ window.setContentSize(NSSize(width: 900, height: 600))
+ viewController.loadViewIfNeeded()
- sidebar.clickWorkspaceHeaderForTesting(firstWorkspace)
- _ = try await awaitTransportRender(transport)
+ let accountsViewController = viewController
+ .sidebarViewControllerForTesting
+ .accountsViewControllerForTesting
+ let displayedOtherAccount = try #require(
+ store.auth.persistedAccounts.first { $0.email == "other@example.com" }
+ )
- let selectedSection = try #require(sidebar.selectedWorkspaceSectionForTesting)
- #expect(selectedSection.title == "CodexReviewKit")
- #expect(selectedSection.workspaceCWDs == [firstWorkspace.cwd, secondWorkspace.cwd])
- #expect(transport.workspaceFindingSnapshotForTesting.text.contains("Keep first worktree visible"))
- #expect(transport.workspaceFindingSnapshotForTesting.text.contains("Keep second worktree visible"))
- #expect(transport.workspaceFindingSnapshotForTesting.text.contains("Sources/First.swift:1-1"))
- #expect(transport.workspaceFindingSnapshotForTesting.text.contains("Sources/Second.swift:2-2"))
- try await waitForCondition {
- window.title == "CodexReviewKit" &&
- window.subtitle == "2 workspaces"
+ try await waitForObservedValue(
+ from: accountsViewController.accountListObservationForTesting,
+ true
+ ) {
+ accountsViewController.selectedAccountEmailForTesting == "active@example.com"
}
- }
- @Test func sidebarLatestFinishedFilterUsesLinkedWorktreeGroupLatestJob() async throws {
- let fixture = try makeLinkedWorktreeFixtureForTesting(repositoryName: "CodexReviewKit")
- defer {
- try? FileManager.default.removeItem(at: fixture.rootURL)
+ #expect(accountsViewController.selectedAccountEmailForTesting == "active@example.com")
+ #expect(accountsViewController.allowsUserSelectionForTesting(displayedOtherAccount) == false)
+ for _ in 0..<10 {
+ await Task.yield()
}
- let firstWorkspace = CodexReviewWorkspace(cwd: fixture.firstWorktreeURL.path)
- let secondWorkspace = CodexReviewWorkspace(cwd: fixture.secondWorktreeURL.path)
- let firstJob = makeJob(
- id: "job-first-worktree-older-finished",
- cwd: firstWorkspace.cwd,
- startedAt: Date(timeIntervalSince1970: 100),
- status: .succeeded,
- targetSummary: "First worktree older finished"
- )
- let secondJob = makeJob(
- id: "job-second-worktree-newer-finished",
- cwd: secondWorkspace.cwd,
- startedAt: Date(timeIntervalSince1970: 200),
- status: .failed,
- targetSummary: "Second worktree newer finished"
- )
+ #expect(accountsViewController.selectedAccountEmailForTesting == "active@example.com")
+ #expect(store.auth.selectedAccount?.email == "active@example.com")
+ #expect(backend.switchAccountCallCount() == 0)
+ }
+
+ @Test func accountDragUsesClickedRowWithoutChangingAuthSelection() async throws {
+ let activeAccount = CodexReviewAccount(email: "active@example.com", planType: "pro")
+ let otherAccount = CodexReviewAccount(email: "other@example.com", planType: "plus")
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
- workspaces: [firstWorkspace, secondWorkspace],
- jobs: [firstJob, secondJob]
+ account: activeAccount,
+ persistedAccounts: [activeAccount, otherAccount]
)
let uiState = ReviewMonitorUIState(auth: store.auth)
- uiState.sidebarJobFilter = .latestFinished
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
+ uiState.sidebarSelection = .account
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(store: store, uiState: uiState)
viewController.loadViewIfNeeded()
- let sidebar = viewController.sidebarViewControllerForTesting
- #expect(sidebar.displayedSectionTitlesForTesting == ["CodexReviewKit"])
- #expect(sidebar.displayedJobIDsForTesting(in: firstWorkspace) == [])
- #expect(sidebar.displayedJobIDsForTesting(in: secondWorkspace) == ["job-second-worktree-newer-finished"])
-
- firstJob.updateStateForTesting(endedAt: Date(timeIntervalSince1970: 400))
+ let accountsViewController = viewController
+ .sidebarViewControllerForTesting
+ .accountsViewControllerForTesting
+ let displayedOtherAccount = try #require(
+ store.auth.persistedAccounts.first { $0.email == "other@example.com" }
+ )
try await waitForObservedValue(
- from: sidebar.sidebarTopologyObservationForTesting,
- ["job-first-worktree-older-finished"]
+ from: accountsViewController.accountListObservationForTesting,
+ true
) {
- sidebar.displayedJobIDsForTesting(in: firstWorkspace)
+ accountsViewController.selectedAccountEmailForTesting == "active@example.com"
}
- #expect(sidebar.displayedJobIDsForTesting(in: secondWorkspace) == [])
+ #expect(
+ accountsViewController.dragPasteboardAccountKeyForTesting(displayedOtherAccount)
+ == displayedOtherAccount.accountKey)
+ #expect(accountsViewController.selectedAccountEmailForTesting == "active@example.com")
+ #expect(store.auth.selectedAccount?.email == "active@example.com")
}
- @Test func sidebarRunningAndLatestFinishedFilterUsesLinkedWorktreeGroupLatestJob() async throws {
- let fixture = try makeLinkedWorktreeFixtureForTesting(repositoryName: "CodexReviewKit")
- defer {
- try? FileManager.default.removeItem(at: fixture.rootURL)
- }
-
- let firstWorkspace = CodexReviewWorkspace(cwd: fixture.firstWorktreeURL.path)
- let secondWorkspace = CodexReviewWorkspace(cwd: fixture.secondWorktreeURL.path)
- let firstRunningJob = makeJob(
- id: "job-first-worktree-running-a",
- cwd: firstWorkspace.cwd,
- startedAt: Date(timeIntervalSince1970: 300),
- status: .running,
- targetSummary: "First worktree running A"
- )
- let firstHiddenFinishedJob = makeJob(
- id: "job-first-worktree-hidden-finished",
- cwd: firstWorkspace.cwd,
- startedAt: Date(timeIntervalSince1970: 100),
- status: .succeeded,
- targetSummary: "First worktree hidden finished"
- )
- let firstQueuedJob = makeJob(
- id: "job-first-worktree-queued-b",
- cwd: firstWorkspace.cwd,
- startedAt: Date(timeIntervalSince1970: 350),
- status: .queued,
- targetSummary: "First worktree queued B"
- )
- let secondQueuedJob = makeJob(
- id: "job-second-worktree-queued",
- cwd: secondWorkspace.cwd,
- startedAt: Date(timeIntervalSince1970: 400),
- status: .queued,
- targetSummary: "Second worktree queued"
- )
- let secondLatestFinishedJob = makeJob(
- id: "job-second-worktree-latest-finished",
- cwd: secondWorkspace.cwd,
- startedAt: Date(timeIntervalSince1970: 200),
- status: .failed,
- targetSummary: "Second worktree latest finished"
- )
+ @Test func accountBlankClickKeepsAuthenticatedSelection() async throws {
+ let activeAccount = CodexReviewAccount(email: "active@example.com", planType: "pro")
+ let otherAccount = CodexReviewAccount(email: "other@example.com", planType: "plus")
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
- workspaces: [firstWorkspace, secondWorkspace],
- jobs: [
- firstRunningJob,
- firstHiddenFinishedJob,
- firstQueuedJob,
- secondQueuedJob,
- secondLatestFinishedJob,
- ]
+ account: activeAccount,
+ persistedAccounts: [activeAccount, otherAccount]
)
let uiState = ReviewMonitorUIState(auth: store.auth)
- uiState.sidebarJobFilter = [.running, .latestFinished]
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState)
+ uiState.sidebarSelection = .account
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(store: store, uiState: uiState)
+ let window = NSWindow(contentViewController: viewController)
+ defer { window.close() }
+ window.setContentSize(NSSize(width: 900, height: 600))
viewController.loadViewIfNeeded()
+ viewController.view.layoutSubtreeIfNeeded()
- let sidebar = viewController.sidebarViewControllerForTesting
- #expect(sidebar.displayedSectionTitlesForTesting == ["CodexReviewKit"])
- #expect(sidebar.displayedJobIDsForTesting(in: firstWorkspace) == [
- "job-first-worktree-running-a",
- "job-first-worktree-queued-b",
- ])
- #expect(sidebar.displayedJobIDsForTesting(in: secondWorkspace) == [
- "job-second-worktree-queued",
- "job-second-worktree-latest-finished",
- ])
-
- #expect(sidebar.performJobDropForTesting(
- firstRunningJob,
- proposedWorkspaceSectionContaining: secondWorkspace,
- childIndex: 3
- ) == false)
-
- #expect(sidebar.performJobDropForTesting(
- firstRunningJob,
- proposedWorkspaceSectionContaining: firstWorkspace,
- childIndex: 2
- ))
- await Task.yield()
+ let accountsViewController = viewController
+ .sidebarViewControllerForTesting
+ .accountsViewControllerForTesting
+ try await waitForObservedValue(
+ from: accountsViewController.accountListObservationForTesting,
+ true
+ ) {
+ accountsViewController.selectedAccountEmailForTesting == "active@example.com"
+ }
- #expect(sidebar.displayedJobIDsForTesting(in: firstWorkspace) == [
- "job-first-worktree-queued-b",
- "job-first-worktree-running-a",
- ])
- #expect(sidebar.displayedJobIDsForTesting(in: secondWorkspace) == [
- "job-second-worktree-queued",
- "job-second-worktree-latest-finished",
- ])
- #expect(store.orderedJobs(in: firstWorkspace).map(\.id) == [
- "job-first-worktree-hidden-finished",
- "job-first-worktree-queued-b",
- "job-first-worktree-running-a",
- ])
- }
-
- @Test func workspaceSectionSelectionExpandsWhenLinkedWorktreeArrives() async throws {
- let fixture = try makeLinkedWorktreeFixtureForTesting(repositoryName: "CodexReviewKit")
- defer {
- try? FileManager.default.removeItem(at: fixture.rootURL)
+ accountsViewController.clickBlankAreaForTesting()
+ for _ in 0..<10 {
+ await Task.yield()
}
- let firstWorkspace = CodexReviewWorkspace(cwd: fixture.firstWorktreeURL.path)
- let secondWorkspace = CodexReviewWorkspace(cwd: fixture.secondWorktreeURL.path)
- let firstJob = makeJob(
- id: "job-existing-worktree",
- cwd: firstWorkspace.cwd,
- status: .succeeded,
- targetSummary: "Existing worktree"
- )
- let secondJob = makeJob(
- id: "job-arriving-worktree",
- cwd: secondWorkspace.cwd,
- status: .succeeded,
- targetSummary: "Arriving worktree"
- )
+
+ #expect(accountsViewController.selectedAccountEmailForTesting == "active@example.com")
+ #expect(store.auth.selectedAccount?.email == "active@example.com")
+ }
+
+ @Test func accountSelectionChangeKeepsDisplayedAccounts() async throws {
+ let activeAccount = CodexReviewAccount(email: "active@example.com", planType: "pro")
+ let otherAccount = CodexReviewAccount(email: "other@example.com", planType: "plus")
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
- workspaces: [firstWorkspace],
- jobs: [firstJob]
+ account: activeAccount,
+ persistedAccounts: [activeAccount, otherAccount]
)
- let backend = makeWindowHarness(store: store)
- let viewController = backend.viewController
- let window = backend.window
+ let uiState = ReviewMonitorUIState(auth: store.auth)
+ uiState.sidebarSelection = .account
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(store: store, uiState: uiState)
+ let window = NSWindow(contentViewController: viewController)
defer { window.close() }
- let sidebar = viewController.sidebarViewControllerForTesting
- sidebar.selectWorkspaceForTesting(firstWorkspace)
- #expect(sidebar.selectedWorkspaceSectionForTesting?.workspaceCWDs == [firstWorkspace.cwd])
+ window.setContentSize(NSSize(width: 900, height: 600))
+ viewController.loadViewIfNeeded()
- store.loadForTesting(
- serverState: .running,
- workspaces: [firstWorkspace, secondWorkspace],
- jobs: [firstJob, secondJob]
+ let accountsViewController = viewController
+ .sidebarViewControllerForTesting
+ .accountsViewControllerForTesting
+ let displayedOtherAccount = try #require(
+ store.auth.persistedAccounts.first { $0.email == "other@example.com" }
)
try await waitForObservedValue(
- from: sidebar.sidebarTopologyObservationForTesting,
- ["CodexReviewKit"]
+ from: accountsViewController.accountListObservationForTesting,
+ true
) {
- sidebar.displayedSectionTitlesForTesting
+ accountsViewController.selectedAccountEmailForTesting == "active@example.com"
}
- try await waitForCondition {
- sidebar.selectedWorkspaceSectionForTesting?.workspaceCWDs == [firstWorkspace.cwd, secondWorkspace.cwd]
+ let displayedEmails = accountsViewController.displayedAccountEmailsForTesting
+ let fullReloadCountBeforeSelectionChange = accountsViewController.accountFullReloadCountForTesting
+ let incrementalMembershipChangeCountBeforeSelectionChange = accountsViewController
+ .accountIncrementalMembershipChangeCountForTesting
+ let incrementalMoveCountBeforeSelectionChange = accountsViewController.accountIncrementalMoveCountForTesting
+
+ store.auth.selectPersistedAccount(displayedOtherAccount.accountKey)
+ try await waitForObservedValue(
+ from: accountsViewController.accountListObservationForTesting,
+ true
+ ) {
+ accountsViewController.selectedAccountEmailForTesting == "other@example.com"
}
- #expect(window.title == "CodexReviewKit")
- #expect(window.subtitle == "2 workspaces")
+ #expect(accountsViewController.selectedAccountEmailForTesting == "other@example.com")
+ #expect(accountsViewController.displayedAccountEmailsForTesting == displayedEmails)
+ #expect(accountsViewController.accountFullReloadCountForTesting == fullReloadCountBeforeSelectionChange)
+ #expect(
+ accountsViewController.accountIncrementalMembershipChangeCountForTesting
+ == incrementalMembershipChangeCountBeforeSelectionChange
+ )
+ #expect(
+ accountsViewController.accountIncrementalMoveCountForTesting == incrementalMoveCountBeforeSelectionChange)
}
- @Test func workspaceSectionSelectionShrinksWhenLinkedWorktreeLeaves() async throws {
- let fixture = try makeLinkedWorktreeFixtureForTesting(repositoryName: "CodexReviewKit")
- defer {
- try? FileManager.default.removeItem(at: fixture.rootURL)
- }
- let firstWorkspace = CodexReviewWorkspace(cwd: fixture.firstWorktreeURL.path)
- let secondWorkspace = CodexReviewWorkspace(cwd: fixture.secondWorktreeURL.path)
- let firstJob = makeJob(
- id: "job-remaining-worktree",
- cwd: firstWorkspace.cwd,
- status: .succeeded,
- targetSummary: "Remaining worktree"
- )
- let secondJob = makeJob(
- id: "job-removed-worktree",
- cwd: secondWorkspace.cwd,
- status: .succeeded,
- targetSummary: "Removed worktree"
- )
+ @Test func accountContentUpdateDoesNotReloadOutlineTopology() async throws {
+ let activeAccount = CodexReviewAccount(email: "active@example.com", planType: "pro")
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
- workspaces: [firstWorkspace, secondWorkspace],
- jobs: [firstJob, secondJob]
+ account: activeAccount,
+ persistedAccounts: [activeAccount]
)
- let backend = makeWindowHarness(store: store)
- let viewController = backend.viewController
- let window = backend.window
- defer { window.close() }
- let sidebar = viewController.sidebarViewControllerForTesting
- sidebar.clickWorkspaceHeaderForTesting(firstWorkspace)
- #expect(sidebar.selectedWorkspaceSectionForTesting?.title == "CodexReviewKit")
+ let uiState = ReviewMonitorUIState(auth: store.auth)
+ uiState.sidebarSelection = .account
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(store: store, uiState: uiState)
+ viewController.loadViewIfNeeded()
- store.loadForTesting(
- serverState: .running,
- workspaces: [firstWorkspace],
- jobs: [firstJob]
- )
- try await waitForCondition {
- sidebar.selectedWorkspaceSectionForTesting?.workspaceCWDs == [firstWorkspace.cwd]
+ let accountsViewController = viewController
+ .sidebarViewControllerForTesting
+ .accountsViewControllerForTesting
+ let displayedActiveAccount = try #require(store.auth.persistedAccounts.first)
+ let fullReloadCountBeforeUpdate = accountsViewController.accountFullReloadCountForTesting
+ let incrementalMembershipChangeCountBeforeUpdate = accountsViewController
+ .accountIncrementalMembershipChangeCountForTesting
+ let incrementalMoveCountBeforeUpdate = accountsViewController.accountIncrementalMoveCountForTesting
+
+ let updatedAccount = CodexReviewAccount(email: "active@example.com", planType: "team")
+ store.auth.applyPersistedAccountStates([savedAccountPayload(from: updatedAccount)])
+ for _ in 0..<10 {
+ await Task.yield()
}
- #expect(sidebar.displayedSectionTitlesForTesting == ["CodexReviewKit"])
- #expect(window.title == "CodexReviewKit")
- #expect(window.subtitle == firstWorkspace.cwd)
+ #expect(store.auth.persistedAccounts.first === displayedActiveAccount)
+ #expect(store.auth.persistedAccounts.first?.planType == "team")
+ #expect(accountsViewController.displayedAccountEmailsForTesting == ["active@example.com"])
+ #expect(accountsViewController.accountFullReloadCountForTesting == fullReloadCountBeforeUpdate)
+ #expect(
+ accountsViewController.accountIncrementalMembershipChangeCountForTesting
+ == incrementalMembershipChangeCountBeforeUpdate
+ )
+ #expect(accountsViewController.accountIncrementalMoveCountForTesting == incrementalMoveCountBeforeUpdate)
}
- @Test func workspaceSectionDropAcrossSectionRootUsesVisibleRootIndexes() async throws {
- let fixture = try makeLinkedWorktreeFixtureForTesting(repositoryName: "CodexReviewKit")
- defer {
- try? FileManager.default.removeItem(at: fixture.rootURL)
- }
- let standaloneURL = fixture.rootURL.appendingPathComponent("Standalone", isDirectory: true)
- try FileManager.default.createDirectory(at: standaloneURL, withIntermediateDirectories: true)
- let firstWorkspace = CodexReviewWorkspace(cwd: fixture.firstWorktreeURL.path)
- let secondWorkspace = CodexReviewWorkspace(cwd: fixture.secondWorktreeURL.path)
- let standaloneWorkspace = CodexReviewWorkspace(cwd: standaloneURL.path)
- let firstJob = makeJob(
- id: "job-grouped-first-workspace",
- cwd: firstWorkspace.cwd,
- status: .succeeded,
- targetSummary: "First workspace"
- )
- let secondJob = makeJob(
- id: "job-grouped-second-workspace",
- cwd: secondWorkspace.cwd,
- status: .running,
- targetSummary: "Second workspace"
- )
- let standaloneJob = makeJob(
- id: "job-standalone-workspace",
- cwd: standaloneWorkspace.cwd,
- status: .queued,
- targetSummary: "Standalone workspace"
- )
+ @Test func accountListTracksDetachedCurrentSessionMembership() async throws {
+ let savedAccount = CodexReviewAccount(email: "saved@example.com", planType: "pro")
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
- workspaces: [firstWorkspace, secondWorkspace, standaloneWorkspace],
- jobs: [firstJob, secondJob, standaloneJob]
+ account: savedAccount,
+ persistedAccounts: [savedAccount]
)
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ let uiState = ReviewMonitorUIState(auth: store.auth)
+ uiState.sidebarSelection = .account
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(store: store, uiState: uiState)
+ let window = NSWindow(contentViewController: viewController)
+ defer { window.close() }
+ window.setContentSize(NSSize(width: 900, height: 600))
viewController.loadViewIfNeeded()
- let sidebar = viewController.sidebarViewControllerForTesting
- #expect(sidebar.displayedSectionTitlesForTesting == ["CodexReviewKit", "Standalone"])
- let incrementalMoveCountBeforeDrop = sidebar.sidebarIncrementalMoveCountForTesting
- #expect(sidebar.performWorkspaceDropForTesting(standaloneWorkspace, toIndex: 0))
- await Task.yield()
+ let accountsViewController = viewController
+ .sidebarViewControllerForTesting
+ .accountsViewControllerForTesting
+ #expect(accountsViewController.displayedAccountEmailsForTesting == ["saved@example.com"])
+ let fullReloadCountBeforeMembershipChanges = accountsViewController.accountFullReloadCountForTesting
+ let incrementalMembershipChangeCountBeforeMembershipChanges = accountsViewController
+ .accountIncrementalMembershipChangeCountForTesting
+ let incrementalMoveCountBeforeMembershipChanges = accountsViewController.accountIncrementalMoveCountForTesting
+
+ store.auth.updateCurrentAccount(CodexReviewAccount(email: "detached@example.com", planType: "pro"))
+ try await waitForObservedValue(
+ from: accountsViewController.accountListObservationForTesting,
+ [
+ "saved@example.com",
+ "detached@example.com",
+ ]
+ ) {
+ accountsViewController.displayedAccountEmailsForTesting
+ }
+
+ #expect(
+ accountsViewController.displayedAccountEmailsForTesting == [
+ "saved@example.com",
+ "detached@example.com",
+ ])
+ #expect(accountsViewController.selectedAccountEmailForTesting == "detached@example.com")
+ #expect(accountsViewController.accountFullReloadCountForTesting == fullReloadCountBeforeMembershipChanges)
+ #expect(
+ accountsViewController.accountIncrementalMembershipChangeCountForTesting
+ == incrementalMembershipChangeCountBeforeMembershipChanges + 1
+ )
+ #expect(
+ accountsViewController.accountIncrementalMoveCountForTesting == incrementalMoveCountBeforeMembershipChanges)
+
+ store.auth.selectPersistedAccount(savedAccount.accountKey)
+ try await waitForObservedValue(
+ from: accountsViewController.accountListObservationForTesting,
+ ["saved@example.com"]
+ ) {
+ accountsViewController.displayedAccountEmailsForTesting
+ }
- #expect(sidebar.displayedSectionTitlesForTesting == ["Standalone", "CodexReviewKit"])
- #expect(sidebar.sidebarIncrementalMoveCountForTesting == incrementalMoveCountBeforeDrop + 1)
+ #expect(accountsViewController.displayedAccountEmailsForTesting == ["saved@example.com"])
+ #expect(accountsViewController.selectedAccountEmailForTesting == "saved@example.com")
+ #expect(accountsViewController.accountFullReloadCountForTesting == fullReloadCountBeforeMembershipChanges)
+ #expect(
+ accountsViewController.accountIncrementalMembershipChangeCountForTesting
+ == incrementalMembershipChangeCountBeforeMembershipChanges + 2
+ )
+ #expect(
+ accountsViewController.accountIncrementalMoveCountForTesting == incrementalMoveCountBeforeMembershipChanges)
}
- @Test func workspaceSectionDropReordersSectionRootAsBlock() async throws {
- let fixture = try makeLinkedWorktreeFixtureForTesting(repositoryName: "CodexReviewKit")
- defer {
- try? FileManager.default.removeItem(at: fixture.rootURL)
- }
- let standaloneURL = fixture.rootURL.appendingPathComponent("Standalone", isDirectory: true)
- try FileManager.default.createDirectory(at: standaloneURL, withIntermediateDirectories: true)
- let firstWorkspace = CodexReviewWorkspace(cwd: fixture.firstWorktreeURL.path)
- let secondWorkspace = CodexReviewWorkspace(cwd: fixture.secondWorktreeURL.path)
- let standaloneWorkspace = CodexReviewWorkspace(cwd: standaloneURL.path)
- let firstJob = makeJob(
- id: "job-draggable-group-first-workspace",
- cwd: firstWorkspace.cwd,
- status: .succeeded,
- targetSummary: "First workspace"
- )
- let secondJob = makeJob(
- id: "job-draggable-group-second-workspace",
- cwd: secondWorkspace.cwd,
- status: .running,
- targetSummary: "Second workspace"
- )
- let standaloneJob = makeJob(
- id: "job-draggable-group-standalone",
- cwd: standaloneWorkspace.cwd,
- status: .queued,
- targetSummary: "Standalone workspace"
- )
+ @Test func accountActionAlertRestoresSelectionToAuthenticatedAccount() async throws {
+ let activeAccount = CodexReviewAccount(email: "active@example.com", planType: "pro")
+ let otherAccount = CodexReviewAccount(email: "other@example.com", planType: "plus")
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
- workspaces: [firstWorkspace, secondWorkspace, standaloneWorkspace],
- jobs: [firstJob, secondJob, standaloneJob]
+ account: activeAccount,
+ persistedAccounts: [activeAccount, otherAccount]
)
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ let uiState = ReviewMonitorUIState(auth: store.auth)
+ uiState.sidebarSelection = .account
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(store: store, uiState: uiState)
+ let window = NSWindow(contentViewController: viewController)
+ defer { window.close() }
+ window.setContentSize(NSSize(width: 900, height: 600))
viewController.loadViewIfNeeded()
- let sidebar = viewController.sidebarViewControllerForTesting
- #expect(sidebar.displayedSectionTitlesForTesting == ["CodexReviewKit", "Standalone"])
- #expect(sidebar.workspaceSectionCanStartDragForTesting(containing: firstWorkspace))
- let incrementalMoveCountBeforeDrop = sidebar.sidebarIncrementalMoveCountForTesting
- #expect(sidebar.performWorkspaceSectionDropForTesting(
- containing: firstWorkspace,
- toIndex: 2
- ))
- await Task.yield()
+ let accountsViewController = viewController
+ .sidebarViewControllerForTesting
+ .accountsViewControllerForTesting
+ let displayedOtherAccount = try #require(
+ store.auth.persistedAccounts.first { $0.email == "other@example.com" }
+ )
- #expect(sidebar.displayedSectionTitlesForTesting == ["Standalone", "CodexReviewKit"])
- #expect(store.orderedWorkspaces.map(\.cwd) == [
- standaloneWorkspace.cwd,
- firstWorkspace.cwd,
- secondWorkspace.cwd,
- ])
- #expect(sidebar.sidebarIncrementalMoveCountForTesting == incrementalMoveCountBeforeDrop + 1)
- }
+ accountsViewController.selectAccountRowForTesting(displayedOtherAccount)
+ #expect(accountsViewController.selectedAccountEmailForTesting == "other@example.com")
- @Test func workspaceSectionDropBlockifiesNonContiguousGroupedWorkspaces() async throws {
- let fixture = try makeLinkedWorktreeFixtureForTesting(repositoryName: "CodexReviewKit")
- defer {
- try? FileManager.default.removeItem(at: fixture.rootURL)
- }
- let standaloneBURL = fixture.rootURL.appendingPathComponent("StandaloneB", isDirectory: true)
- let standaloneCURL = fixture.rootURL.appendingPathComponent("StandaloneC", isDirectory: true)
- try FileManager.default.createDirectory(at: standaloneBURL, withIntermediateDirectories: true)
- try FileManager.default.createDirectory(at: standaloneCURL, withIntermediateDirectories: true)
- let firstWorkspace = CodexReviewWorkspace(cwd: fixture.firstWorktreeURL.path)
- let secondWorkspace = CodexReviewWorkspace(cwd: fixture.secondWorktreeURL.path)
- let standaloneBWorkspace = CodexReviewWorkspace(cwd: standaloneBURL.path)
- let standaloneCWorkspace = CodexReviewWorkspace(cwd: standaloneCURL.path)
- let firstJob = makeJob(
- id: "job-noncontiguous-group-first",
- cwd: firstWorkspace.cwd,
- status: .succeeded,
- targetSummary: "First grouped workspace"
- )
- let standaloneBJob = makeJob(
- id: "job-noncontiguous-standalone-b",
- cwd: standaloneBWorkspace.cwd,
- status: .running,
- targetSummary: "Standalone B"
- )
- let standaloneCJob = makeJob(
- id: "job-noncontiguous-standalone-c",
- cwd: standaloneCWorkspace.cwd,
- status: .queued,
- targetSummary: "Standalone C"
- )
- let secondJob = makeJob(
- id: "job-noncontiguous-group-second",
- cwd: secondWorkspace.cwd,
- status: .failed,
- targetSummary: "Second grouped workspace"
- )
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- workspaces: [firstWorkspace, standaloneBWorkspace, standaloneCWorkspace, secondWorkspace],
- jobs: [firstJob, standaloneBJob, standaloneCJob, secondJob]
+ store.auth.presentAccountActionAlert(
+ title: "Failed to Switch Accounts",
+ message: "Request failed."
)
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
- viewController.loadViewIfNeeded()
- let sidebar = viewController.sidebarViewControllerForTesting
- #expect(sidebar.displayedSectionTitlesForTesting == ["CodexReviewKit", "StandaloneB", "StandaloneC"])
-
- #expect(sidebar.performWorkspaceSectionDropForTesting(
- containing: firstWorkspace,
- toIndex: 2
- ))
- await Task.yield()
+ try await waitForObservedValue(
+ from: accountsViewController.accountPromptObservationForTesting,
+ true
+ ) {
+ accountsViewController.selectedAccountEmailForTesting == "active@example.com"
+ }
- #expect(sidebar.displayedSectionTitlesForTesting == ["StandaloneB", "CodexReviewKit", "StandaloneC"])
- #expect(store.orderedWorkspaces.map(\.cwd) == [
- standaloneBWorkspace.cwd,
- firstWorkspace.cwd,
- secondWorkspace.cwd,
- standaloneCWorkspace.cwd,
- ])
+ #expect(accountsViewController.selectedAccountEmailForTesting == "active@example.com")
}
- @Test func workspaceSectionJobDropUsesRootChildIndexesForLaterWorkspaceJobs() async throws {
- let fixture = try makeLinkedWorktreeFixtureForTesting(repositoryName: "CodexReviewKit")
- defer {
- try? FileManager.default.removeItem(at: fixture.rootURL)
- }
- let firstWorkspace = CodexReviewWorkspace(cwd: fixture.firstWorktreeURL.path)
- let secondWorkspace = CodexReviewWorkspace(cwd: fixture.secondWorktreeURL.path)
- let firstWorkspaceJob = makeJob(
- id: "job-first-workspace",
- cwd: firstWorkspace.cwd,
- status: .succeeded,
- targetSummary: "First workspace"
- )
- let secondWorkspaceFirstJob = makeJob(
- id: "job-second-workspace-first",
- cwd: secondWorkspace.cwd,
- status: .running,
- targetSummary: "Second workspace first"
- )
- let secondWorkspaceSecondJob = makeJob(
- id: "job-second-workspace-second",
- cwd: secondWorkspace.cwd,
- status: .queued,
- targetSummary: "Second workspace second"
- )
+ @Test func reviewChatsPresentOnInitialLoadStayUnselected() {
+ let activeChat = makeReviewChatFixtureForTesting(title: "Uncommitted changes", status: .running)
+ let recentChat = makeReviewChatFixtureForTesting(title: "Commit: abc123", status: .succeeded)
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
- workspaces: [firstWorkspace, secondWorkspace],
- jobs: [firstWorkspaceJob, secondWorkspaceFirstJob, secondWorkspaceSecondJob]
+ fixtures: [activeChat, recentChat]
)
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
viewController.loadViewIfNeeded()
- let sidebar = viewController.sidebarViewControllerForTesting
- #expect(sidebar.displayedSectionTitlesForTesting == ["CodexReviewKit"])
- #expect(sidebar.displayedJobIDsForTesting(in: secondWorkspace) == [
- "job-second-workspace-first",
- "job-second-workspace-second",
- ])
-
- #expect(sidebar.performJobDropForTesting(
- secondWorkspaceFirstJob,
- proposedWorkspaceSectionContaining: secondWorkspace,
- childIndex: 0
- ) == false)
-
- #expect(sidebar.performJobDropForTesting(
- secondWorkspaceFirstJob,
- proposedJob: firstWorkspaceJob,
- hoveringBelowMidpoint: true
- ) == false)
-
- #expect(sidebar.performJobDropForTesting(
- secondWorkspaceFirstJob,
- proposedJob: secondWorkspaceSecondJob,
- hoveringBelowMidpoint: true
- ))
- await Task.yield()
- #expect(sidebar.displayedJobIDsForTesting(in: firstWorkspace) == ["job-first-workspace"])
- #expect(sidebar.displayedJobIDsForTesting(in: secondWorkspace) == [
- "job-second-workspace-second",
- "job-second-workspace-first",
- ])
+ #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == nil)
+ #expect(viewController.contentPaneViewControllerForTesting.isShowingEmptyStateForTesting)
}
- @Test func workspaceFindingsTextWrapsWithinDetailWidth() async throws {
- let workspaceCWD = "/tmp/workspace-alpha"
- let longBody = Array(repeating: "structured finding text should wrap inside the detail pane", count: 12)
- .joined(separator: " ")
- let job = makeJob(
- id: "job-long-finding",
- cwd: workspaceCWD,
- status: .succeeded,
- targetSummary: "Branch: long-finding",
- reviewResult: .init(
- state: .hasFindings,
- findingCount: 1,
- findings: [
- .init(
- title: "[P2] Keep workspace finding rows constrained to the visible detail width",
- body: longBody,
- priority: 2,
- location: .init(
- path: "\(workspaceCWD)/Sources/VeryLongFinding.swift",
- startLine: 42,
- endLine: 47
- ),
- rawText: ""
- )
- ],
- source: .parsedFinalReviewText
- )
- )
- let workspace = CodexReviewWorkspace(cwd: workspaceCWD)
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, workspaces: [workspace], jobs: [job])
- let backend = makeWindowHarness(
- store: store,
- contentSize: NSSize(width: 560, height: 360)
+ @Test func selectingReviewChatUpdatesDetailPane() async throws {
+ let activeChat = makeReviewChatFixtureForTesting(
+ title: "Uncommitted changes",
+ status: .running,
+ chatEntries: [.init(kind: .agentMessage, text: "Running review")]
)
- let viewController = backend.viewController
- defer { backend.window.close() }
- let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectWorkspaceForTesting(workspace)
-
- _ = try await awaitTransportRender(transport)
- let contentWidth = transport.workspaceFindingsContentWidthForTesting
- let textContainerWidth = transport.workspaceFindingsTextContainerWidthForTesting
- #expect(textContainerWidth > 0)
- #expect(textContainerWidth <= contentWidth + 0.5)
- }
-
- @Test func workspaceFindingsViewExtendsBehindTitlebarWithoutOverlappingSidebar() async throws {
- let workspaceCWD = "/tmp/workspace-alpha"
- let job = makeJob(
- id: "job-finding-layout",
- cwd: workspaceCWD,
+ let recentChat = makeReviewChatFixtureForTesting(
+ title: "Commit: abc123",
+ preview: "MCP server codex_review ready.",
status: .succeeded,
- targetSummary: "Branch: finding-layout",
- reviewResult: .init(
- state: .hasFindings,
- findingCount: 1,
- findings: [
- .init(
- title: "[P2] Keep workspace finding rows visible under unified titlebar",
- body: "Structured finding body",
- priority: 2,
- location: nil,
- rawText: ""
- )
- ],
- source: .parsedFinalReviewText
- )
+ chatEntries: [.init(kind: .agentMessage, text: "Findings ready")]
)
- let workspace = CodexReviewWorkspace(cwd: workspaceCWD)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, workspaces: [workspace], jobs: [job])
- let backend = makeWindowHarness(
- store: store,
- contentSize: NSSize(width: 900, height: 600)
- )
- let viewController = backend.viewController
- defer { backend.window.close() }
- let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectWorkspaceForTesting(workspace)
-
- _ = try await awaitTransportRender(transport)
- backend.window.layoutIfNeeded()
- transport.view.layoutSubtreeIfNeeded()
-
- let findingsFrame = transport.workspaceFindingsFrameForTesting
- let findingsScrollFrame = transport.workspaceFindingsScrollFrameForTesting
- let viewBounds = transport.viewBoundsForTesting
- let safeAreaFrame = transport.safeAreaFrameForTesting
- let contentInsets = transport.workspaceFindingsContentInsetsForTesting
-
- #expect(abs(findingsFrame.minX - safeAreaFrame.minX) < 0.5)
- #expect(abs(findingsFrame.maxX - safeAreaFrame.maxX) < 0.5)
- #expect(abs(findingsFrame.minY - viewBounds.minY) < 0.5)
- #expect(abs(findingsFrame.maxY - viewBounds.maxY) < 0.5)
- #expect(abs(findingsScrollFrame.minX) < 0.5)
- #expect(abs(findingsScrollFrame.width - findingsFrame.width) < 0.5)
- #expect(abs(findingsScrollFrame.minY) < 0.5)
- #expect(abs(findingsScrollFrame.height - findingsFrame.height) < 0.5)
- #expect(safeAreaFrame.maxY < viewBounds.maxY)
- #expect(transport.workspaceFindingsAutomaticallyAdjustsContentInsetsForTesting)
- #expect(contentInsets.top > 0)
- #expect(abs(transport.workspaceFindingsVerticalScrollOffsetForTesting + contentInsets.top) < 0.5)
- #expect(abs(
- transport.workspaceFindingsMaximumVerticalScrollOffsetForTesting
- - transport.workspaceFindingsMinimumVerticalScrollOffsetForTesting
- ) < 0.5)
- }
-
- @Test func selectingWorkspaceWithoutStructuredFindingsShowsNoFindingsState() async throws {
- let job = makeJob(
- id: "job-no-findings",
- cwd: "/tmp/workspace-alpha",
- status: .succeeded,
- targetSummary: "Commit: clean",
- reviewResult: .init(
- state: .noFindings,
- findingCount: 0,
- findings: [],
- source: .parsedFinalReviewText
- )
+ store.loadForTesting(
+ serverState: .running,
+ fixtures: [activeChat, recentChat]
)
- let workspace = CodexReviewWorkspace(cwd: job.cwd)
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, workspaces: [workspace], jobs: [job])
let backend = makeWindowHarness(store: store)
let viewController = backend.viewController
let window = backend.window
defer { window.close() }
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectWorkspaceForTesting(workspace)
-
- _ = try await awaitTransportRender(transport)
- backend.window.layoutIfNeeded()
- transport.view.layoutSubtreeIfNeeded()
-
- let findingsFrame = transport.workspaceFindingsFrameForTesting
- let noFindingsPlaceholderFrame = transport.workspaceFindingsNoFindingsFrameForTesting
- let viewBounds = transport.viewBoundsForTesting
- let safeAreaFrame = transport.safeAreaFrameForTesting
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: recentChat.chatID)
- #expect(
- transport.workspaceFindingSnapshotForTesting == .init(
- text: "",
- isShowingNoFindingsState: true,
- isShowingFindingsList: false
- )
+ let selectedSnapshot = try await awaitChatRenderForTesting(
+ recentChat,
+ in: transport,
+ allowIncrementalUpdate: false
)
- #expect(abs(findingsFrame.minX - safeAreaFrame.minX) < 0.5)
- #expect(abs(findingsFrame.maxX - safeAreaFrame.maxX) < 0.5)
- #expect(abs(findingsFrame.minY - viewBounds.minY) < 0.5)
- #expect(abs(findingsFrame.maxY - viewBounds.maxY) < 0.5)
- #expect(abs(noFindingsPlaceholderFrame.minX - safeAreaFrame.minX) < 0.5)
- #expect(abs(noFindingsPlaceholderFrame.maxX - safeAreaFrame.maxX) < 0.5)
- #expect(abs(noFindingsPlaceholderFrame.minY - viewBounds.minY) < 0.5)
- #expect(abs(noFindingsPlaceholderFrame.maxY - viewBounds.maxY) < 0.5)
- #expect(safeAreaFrame.maxY < viewBounds.maxY)
- try await waitForCondition {
- window.title == workspace.displayTitle &&
- window.subtitle == workspace.cwd
- }
- #expect(window.title == workspace.displayTitle)
- #expect(window.subtitle == workspace.cwd)
- }
+ #expect(selectedSnapshot.log == reviewChatLogText(for: recentChat))
+ #expect(selectedSnapshot.isShowingEmptyState == false)
+ #expect(transport.logUsesFindBarForTesting)
+ #expect(transport.logIsIncrementalSearchingEnabledForTesting)
+ #expect(transport.logFindBarVisibleForTesting == false)
- @Test func workspaceSelectionReloadsByCWDAndClearsWhenWorkspaceDisappears() async throws {
- let job = makeJob(
- id: "job-workspace-selection",
- cwd: "/tmp/workspace-alpha",
- status: .running,
- targetSummary: "Uncommitted changes"
- )
- let workspace = CodexReviewWorkspace(cwd: job.cwd)
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, workspaces: [workspace], jobs: [job])
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
- viewController.loadViewIfNeeded()
- let sidebar = viewController.sidebarViewControllerForTesting
- let transport = viewController.transportViewControllerForTesting
- sidebar.selectWorkspaceForTesting(workspace)
- _ = try await awaitTransportRender(transport)
+ let findItem = textFinderMenuItemForTesting(.showFindInterface)
+ #expect(viewController.validateUserInterfaceItem(findItem))
+ viewController.performTextFinderAction(findItem)
+ #expect(transport.logFindBarVisibleForTesting)
- let replacement = CodexReviewWorkspace(cwd: workspace.cwd)
- let replacementJob = makeJob(
- id: "job-workspace-selection-replacement",
- cwd: workspace.cwd,
- status: .succeeded,
- targetSummary: "Commit: replacement"
+ await replaceChatLogTextForTesting(
+ "Old selection log",
+ for: activeChat.chatID,
+ fixtureID: activeChat.id,
+ turnID: activeChat.turnID
+ )
+ await appendChatLogEntryForTesting(
+ .init(kind: .progress, text: "Current selection log after stale mutation"),
+ to: recentChat.chatID,
+ turnID: recentChat.turnID
)
- store.loadForTesting(serverState: .running, workspaces: [replacement], jobs: [replacementJob])
-
- #expect(sidebar.selectedWorkspaceSectionForTesting?.workspaceCWDs == [replacement.cwd])
- #expect(store.orderedJobs(in: replacement).first?.id == "job-workspace-selection-replacement")
- store.loadForTesting(serverState: .running, workspaces: [])
- try await waitForCondition {
- sidebar.selectedWorkspaceSectionForTesting == nil &&
- sidebar.selectedJobForTesting == nil &&
- transport.isShowingEmptyStateForTesting
+ let updatedSnapshot = try await awaitChatRenderForTesting(recentChat, in: transport) { snapshot in
+ snapshot.log.contains("Current selection log after stale mutation")
}
-
- #expect(sidebar.selectedWorkspaceSectionForTesting == nil)
- #expect(sidebar.selectedJobForTesting == nil)
- #expect(transport.isShowingEmptyStateForTesting)
+ #expect(updatedSnapshot.log.contains("Old selection log") == false)
+ #expect(transport.displayedLogForTesting.contains("Old selection log") == false)
}
- @Test func detailPaneRendersSelectedJobMonitorLogProjection() async throws {
- let job = CodexReviewJob.makeForTesting(
- id: "job-monitor-log",
+ @Test func detailPaneRendersSelectedReviewChatLogProjection() async throws {
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-monitor-log",
cwd: "/tmp/workspace-alpha",
- targetSummary: "Uncommitted changes",
- threadID: UUID().uuidString,
- turnID: UUID().uuidString,
+ title: "Uncommitted changes",
+ preview: "No correctness issues found.",
+ turnID: CodexTurnID(rawValue: "turn-monitor-log"),
status: .succeeded,
startedAt: Date(timeIntervalSince1970: 200),
- endedAt: Date(timeIntervalSince1970: 201),
- summary: "Review completed.",
- hasFinalReview: true,
- lastAgentMessage: "No correctness issues found.",
- logEntries: [
+ updatedAt: Date(timeIntervalSince1970: 201),
+ chatEntries: [
.init(
kind: .command,
groupID: "cmd_1",
@@ -3219,26 +853,26 @@ struct ReviewUITests {
commandStatus: "completed"
)
),
- .init(kind: .agentMessage, text: "No correctness issues found.")
+ .init(kind: .agentMessage, text: "No correctness issues found."),
]
)
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
- content: makeSidebarContent(from: [job])
+ fixtures: [chat]
)
let backend = makeWindowHarness(store: store)
let viewController = backend.viewController
let window = backend.window
defer { window.close() }
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
- let selectedSnapshot = try await awaitTransportRender(transport)
- #expect(selectedSnapshot.title == nil)
- #expect(selectedSnapshot.summary == nil)
- #expect(window.title == job.targetSummary)
- #expect(window.subtitle == job.cwd)
+ let selectedSnapshot = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
let displayedLog = transport.displayedLogForTesting
#expect(selectedSnapshot.log == displayedLog)
@@ -3253,22 +887,20 @@ struct ReviewUITests {
#expect(transport.logCommandOutputPanelUsesTextKit2ForTesting == false)
}
- @Test func detailPaneRendersDirectTimelineUpdatesWithoutLogEntryChanges() async throws {
- let job = CodexReviewJob.makeForTesting(
- id: "job-direct-timeline-detail",
+ @Test func detailPaneRendersSelectedChatStreamUpdates() async throws {
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-selected-chat-stream-detail",
cwd: "/tmp/workspace-alpha",
- targetSummary: "Uncommitted changes",
- threadID: UUID().uuidString,
- turnID: UUID().uuidString,
+ title: "Uncommitted changes",
+ turnID: CodexTurnID(rawValue: "turn-selected-chat-stream-detail"),
status: .running,
startedAt: Date(timeIntervalSince1970: 200),
- summary: "Running review.",
- logEntries: []
+ chatEntries: []
)
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
- content: makeSidebarContent(from: [job])
+ fixtures: [chat]
)
let backend = makeWindowHarness(
store: store,
@@ -3278,73 +910,81 @@ struct ReviewUITests {
let window = backend.window
defer { window.close() }
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
- let initialLogEntries = job.logEntries
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
- job.timeline.apply(.itemCompleted(.init(
- id: "message-direct",
- kind: .agentMessage,
- family: .message,
- phase: .completed,
- content: .message(.init(text: "Timeline-only detail update"))
- )))
-
- var snapshot = try await awaitTransportRender(transport)
- #expect(snapshot.log == "Timeline-only detail update")
- #expect(job.logEntries == initialLogEntries)
-
- let startedAt = Date(timeIntervalSince1970: 250)
- job.timeline.apply(.itemCompleted(.init(
- id: "cmd-direct",
- kind: .commandExecution,
- family: .command,
- phase: .completed,
- content: .command(.init(
- command: "swift test",
- output: "Tests passed",
- exitCode: 0,
- status: .completed,
- durationMs: 2_000
- )),
- startedAt: startedAt,
- completedAt: startedAt.addingTimeInterval(2),
- durationMs: 2_000
- )))
+ await appendChatLogEntryForTesting(
+ .init(kind: .agentMessage, groupID: "message-direct", text: "Selected chat detail update"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+
+ var snapshot = try await awaitChatRenderForTesting(chat, in: transport)
+ #expect(snapshot.log == "Selected chat detail update")
+
+ await appendChatLogEntryForTesting(
+ .init(
+ kind: .command,
+ groupID: "cmd-direct",
+ text: "$ swift test",
+ metadata: .init(command: "swift test", commandStatus: "completed")
+ ),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ await appendChatLogEntryForTesting(
+ .init(
+ kind: .commandOutput,
+ groupID: "cmd-direct",
+ text: "Tests passed",
+ metadata: .init(command: "swift test", exitCode: 0, commandStatus: "completed")
+ ),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
- snapshot = try await awaitTransportRender(transport) {
- $0.log.contains("Ran swift test for 2s")
+ snapshot = try await awaitChatRenderForTesting(chat, in: transport) {
+ $0.log.contains("Ran swift test")
}
- #expect(snapshot.log.contains("Timeline-only detail update"))
- #expect(snapshot.log.contains("Ran swift test for 2s"))
+ #expect(snapshot.log.contains("Selected chat detail update"))
+ #expect(snapshot.log.contains("Ran swift test"))
#expect(snapshot.log.contains("$ swift test") == false)
#expect(snapshot.log.contains("Tests passed") == false)
- #expect(job.logEntries == initialLogEntries)
#expect(transport.logCommandOutputPanelCountForTesting == 1)
- let panelBlockID = ReviewMonitorLog.BlockID("commandOutput:cmd-direct")
+ let panelBlockID = chatCommandOutputBlockIDForTesting(turnID: chat.turnID, itemID: "cmd-direct")
#expect(transport.clickLogCommandOutputPanelHeaderForTesting(blockID: panelBlockID))
- await awaitNativeLayoutTurn()
- #expect(transport.logCommandOutputPanelTerminalTextForTesting(blockID: panelBlockID)?.contains("$ swift test") == true)
- #expect(transport.logCommandOutputPanelTerminalTextForTesting(blockID: panelBlockID)?.contains("Tests passed") == true)
+ try await waitForCondition {
+ transport.logRenderIsIdleForTesting
+ && transport.logCommandOutputPanelTerminalTextForTesting(blockID: panelBlockID)?
+ .contains("Tests passed") == true
+ }
+ #expect(
+ transport.logCommandOutputPanelTerminalTextForTesting(blockID: panelBlockID)?.contains("$ swift test")
+ == true)
+ #expect(
+ transport.logCommandOutputPanelTerminalTextForTesting(blockID: panelBlockID)?.contains("Tests passed")
+ == true)
}
- @Test func directTimelineFailedCommandPreservesFailedPanelStatus() async throws {
- let job = CodexReviewJob.makeForTesting(
- id: "job-direct-timeline-failed-command",
+ @Test func selectedChatFailedCommandPreservesFailedPanelStatus() async throws {
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-selected-chat-failed-command",
cwd: "/tmp/workspace-alpha",
- targetSummary: "Uncommitted changes",
- threadID: UUID().uuidString,
- turnID: UUID().uuidString,
+ title: "Uncommitted changes",
+ turnID: CodexTurnID(rawValue: "turn-selected-chat-failed-command"),
status: .running,
startedAt: Date(timeIntervalSince1970: 200),
- summary: "Running review.",
- logEntries: []
+ chatEntries: []
)
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
- content: makeSidebarContent(from: [job])
+ fixtures: [chat]
)
let backend = makeWindowHarness(
store: store,
@@ -3354,108 +994,53 @@ struct ReviewUITests {
let window = backend.window
defer { window.close() }
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
-
- let startedAt = Date(timeIntervalSince1970: 300)
- job.timeline.apply(.itemCompleted(.init(
- id: "cmd-failed-direct",
- kind: .commandExecution,
- family: .command,
- phase: .failed,
- content: .command(.init(
- command: "swift test",
- output: "Tests failed"
- )),
- startedAt: startedAt,
- completedAt: startedAt.addingTimeInterval(4),
- durationMs: 4_000
- )))
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
+
+ await appendChatLogEntryForTesting(
+ .init(
+ kind: .commandOutput,
+ groupID: "cmd-failed-direct",
+ text: "Tests failed",
+ metadata: .init(command: "swift test", commandStatus: "failed")
+ ),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
- let snapshot = try await awaitTransportRender(transport) {
- $0.log.contains("Ran swift test for 4s")
+ let snapshot = try await awaitChatRenderForTesting(chat, in: transport) {
+ $0.log.contains("Ran swift test")
}
#expect(snapshot.log.contains("Tests failed") == false)
#expect(transport.logCommandOutputPanelCountForTesting == 1)
- let panelBlockID = ReviewMonitorLog.BlockID("commandOutput:cmd-failed-direct")
+ let panelBlockID = chatCommandOutputBlockIDForTesting(turnID: chat.turnID, itemID: "cmd-failed-direct")
#expect(transport.clickLogCommandOutputPanelHeaderForTesting(blockID: panelBlockID))
await awaitNativeLayoutTurn()
#expect(transport.logCommandOutputPanelResultTextForTesting == "Failed")
- #expect(transport.logCommandOutputPanelTerminalTextForTesting(blockID: panelBlockID)?.contains("Tests failed") == true)
- }
-
- @Test func directTimelineRunningCommandOutputStaysActive() async throws {
- let job = CodexReviewJob.makeForTesting(
- id: "job-direct-timeline-running-command",
- cwd: "/tmp/workspace-alpha",
- targetSummary: "Uncommitted changes",
- threadID: UUID().uuidString,
- turnID: UUID().uuidString,
- status: .running,
- startedAt: Date(timeIntervalSince1970: 200),
- summary: "Running review.",
- logEntries: []
- )
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- content: makeSidebarContent(from: [job])
- )
- let backend = makeWindowHarness(
- store: store,
- contentSize: NSSize(width: 860, height: 520)
- )
- let viewController = backend.viewController
- let window = backend.window
- defer { window.close() }
- let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
-
- job.timeline.apply(.itemStarted(.init(
- id: "cmd-running-direct",
- kind: .commandExecution,
- family: .command,
- phase: .running,
- content: .command(.init(
- command: "swift test",
- output: "Building..."
- )),
- startedAt: Date(timeIntervalSince1970: 300)
- )))
-
- let snapshot = try await awaitTransportRender(transport) {
- $0.log.contains("Running swift test")
- }
- #expect(snapshot.log.contains("Running swift test"))
- #expect(snapshot.log.contains("Ran swift test") == false)
- #expect(snapshot.log.contains("Building...") == false)
- #expect(transport.logCommandOutputPanelCountForTesting == 1)
-
- let panelBlockID = ReviewMonitorLog.BlockID("commandOutput:cmd-running-direct")
- #expect(transport.clickLogCommandOutputPanelHeaderForTesting(blockID: panelBlockID))
- await awaitNativeLayoutTurn()
- #expect(transport.logCommandOutputPanelResultTextForTesting == "running")
- #expect(transport.logCommandOutputPanelTerminalTextForTesting(blockID: panelBlockID)?.contains("Building...") == true)
+ #expect(
+ transport.logCommandOutputPanelTerminalTextForTesting(blockID: panelBlockID)?.contains("Tests failed")
+ == true)
}
- @Test func directTimelineTerminalPhaseOverridesStaleCommandStatus() async throws {
- let job = CodexReviewJob.makeForTesting(
- id: "job-direct-timeline-stale-command-status",
+ @Test func selectedChatRunningCommandOutputStaysActive() async throws {
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-selected-chat-running-command",
cwd: "/tmp/workspace-alpha",
- targetSummary: "Uncommitted changes",
- threadID: UUID().uuidString,
- turnID: UUID().uuidString,
+ title: "Uncommitted changes",
+ turnID: CodexTurnID(rawValue: "turn-selected-chat-running-command"),
status: .running,
startedAt: Date(timeIntervalSince1970: 200),
- summary: "Running review.",
- logEntries: []
+ chatEntries: []
)
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
- content: makeSidebarContent(from: [job])
+ fixtures: [chat]
)
let backend = makeWindowHarness(
store: store,
@@ -3465,327 +1050,55 @@ struct ReviewUITests {
let window = backend.window
defer { window.close() }
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
-
- job.timeline.apply(.itemCompleted(.init(
- id: "cmd-stale-status-direct",
- kind: .commandExecution,
- family: .command,
- phase: .completed,
- content: .command(.init(
- command: "swift test",
- output: "Tests passed",
- status: .inProgress
- ))
- )))
-
- let snapshot = try await awaitTransportRender(transport) {
- $0.log.contains("Ran swift test")
- }
- #expect(snapshot.log.contains("Running swift test") == false)
- #expect(transport.logCommandOutputPanelCountForTesting == 1)
-
- let panelBlockID = ReviewMonitorLog.BlockID("commandOutput:cmd-stale-status-direct")
- #expect(transport.clickLogCommandOutputPanelHeaderForTesting(blockID: panelBlockID))
- await awaitNativeLayoutTurn()
- #expect(transport.logCommandOutputPanelResultTextForTesting == "Success")
- #expect(transport.logCommandOutputPanelTerminalTextForTesting(blockID: panelBlockID)?.contains("Tests passed") == true)
- }
-
- @Test func timelineProjectionDoesNotAppendWhenExistingBlockPresentationChanges() {
- let timestamp = Date(timeIntervalSince1970: 300)
- var projection = ReviewMonitorTimelineLogProjection()
-
- func document(
- revision: UInt64,
- blocks: [ReviewTimelineDocument.Block]
- ) -> ReviewTimelineDocument {
- ReviewTimelineDocument(
- timelineRevision: .init(rawValue: revision),
- orderedBlockIDs: blocks.map(\.id),
- activeBlockIDs: blocks.filter(\.isActive).map(\.id),
- activeBlockCount: blocks.filter(\.isActive).count,
- latestActivityBlockID: blocks.last?.id,
- terminalStatus: nil,
- terminalSummary: nil,
- terminalResult: nil,
- blocks: blocks
- )
- }
-
- func toolBlock(
- phase: ReviewItemPhase,
- isActive: Bool,
- status: ReviewToolCallStatus
- ) -> ReviewTimelineDocument.Block {
- ReviewTimelineDocument.Block(
- id: "tool-block",
- sourceItemID: "tool-item",
- kind: .mcpToolCall,
- family: .tool,
- phase: phase,
- isActive: isActive,
- primaryText: "Tool output",
- rawTranscriptText: "Tool output",
- content: .toolCall(.init(
- namespace: "codex_review",
- server: "codex_review",
- name: "review_start",
- result: "Tool output",
- status: status
- )),
- createdAt: timestamp,
- updatedAt: timestamp
- )
- }
-
- let initialDocument = document(
- revision: 1,
- blocks: [
- toolBlock(phase: .running, isActive: true, status: .inProgress),
- ]
- )
- _ = projection.render(timelineDocument: initialDocument)
-
- let updatedDocument = document(
- revision: 2,
- blocks: [
- toolBlock(phase: .completed, isActive: false, status: .completed),
- ReviewTimelineDocument.Block(
- id: "message-block",
- sourceItemID: "message-item",
- kind: .agentMessage,
- family: .message,
- phase: .completed,
- isActive: false,
- primaryText: "Review complete.",
- rawTranscriptText: "Review complete.",
- content: .message(.init(text: "Review complete.")),
- createdAt: timestamp,
- updatedAt: timestamp
- ),
- ]
- )
- let updatedLog = projection.render(timelineDocument: updatedDocument)
-
- #expect(updatedLog.text == "Tool output\n\nReview complete.")
- if case .append = updatedLog.lastChange {
- Issue.record("Timeline projection must not append when existing block presentation changed.")
- }
- }
-
- @Test func timelineProjectionRendersToolCallProgressUpdates() {
- let timestamp = Date(timeIntervalSince1970: 300)
- var projection = ReviewMonitorTimelineLogProjection()
-
- func document(revision: UInt64, progress: String) -> ReviewTimelineDocument {
- ReviewTimelineDocument(
- timelineRevision: .init(rawValue: revision),
- orderedBlockIDs: ["tool-progress"],
- activeBlockIDs: ["tool-progress"],
- activeBlockCount: 1,
- latestActivityBlockID: "tool-progress",
- terminalStatus: nil,
- terminalSummary: nil,
- terminalResult: nil,
- blocks: [
- .init(
- id: "tool-progress",
- sourceItemID: "tool-progress",
- kind: .mcpToolCall,
- family: .tool,
- phase: .running,
- isActive: true,
- primaryText: "codex_review.review_start",
- rawTranscriptText: progress,
- content: .toolCall(.init(
- namespace: "codex_review",
- server: "codex_review",
- name: "review_start",
- status: .inProgress,
- progress: progress
- )),
- createdAt: timestamp,
- updatedAt: timestamp
- ),
- ]
- )
- }
-
- let initialLog = projection.render(timelineDocument: document(
- revision: 1,
- progress: "MCP codex_review.review_start started."
- ))
- let updatedLog = projection.render(timelineDocument: document(
- revision: 2,
- progress: "MCP codex_review.review_start still running."
- ))
-
- #expect(initialLog.text == "MCP codex_review.review_start started.")
- #expect(updatedLog.text == "MCP codex_review.review_start still running.")
- }
-
- @Test func timelineProjectionPreservesOutputOnlyCommandMetadata() throws {
- let startedAt = Date(timeIntervalSince1970: 300)
- let completedAt = startedAt.addingTimeInterval(4)
- var projection = ReviewMonitorTimelineLogProjection()
- let document = ReviewTimelineDocument(
- timelineRevision: .init(rawValue: 1),
- orderedBlockIDs: ["cmd-output-only"],
- activeBlockIDs: [],
- activeBlockCount: 0,
- latestActivityBlockID: "cmd-output-only",
- terminalStatus: nil,
- terminalSummary: nil,
- terminalResult: nil,
- blocks: [
- .init(
- id: "cmd-output-only",
- sourceItemID: "cmd-output-only",
- kind: .commandExecution,
- family: .command,
- phase: .failed,
- isActive: false,
- primaryText: "Command output",
- rawTranscriptText: "stderr",
- content: .command(.init(
- title: "",
- command: "",
- output: "stderr",
- exitCode: 2,
- status: .failed,
- durationMs: 4_000
- )),
- createdAt: startedAt,
- updatedAt: completedAt,
- startedAt: startedAt,
- completedAt: completedAt,
- durationMs: 4_000
- ),
- ]
- )
-
- let sourceLog = projection.render(timelineDocument: document)
- let renderedLog = ReviewMonitorCommandOutputDisplayDocument.make(from: sourceLog)
- let panel = try #require(renderedLog.commandOutputPanels.first)
- let metadata = try #require(sourceLog.blocks.first?.metadata)
-
- #expect(sourceLog.blocks.map(\.kind) == [.commandOutput])
- #expect(sourceLog.text == "stderr")
- #expect(panel.isActive == false)
- #expect(panel.title == "Ran command for 4s")
- #expect(panel.exitText == "exit 2")
- #expect(metadata.itemID == "cmd-output-only")
- #expect(metadata.command == nil)
- #expect(metadata.exitCode == 2)
- #expect(metadata.durationMs == 4_000)
- }
-
- @Test func timelineProjectionTreatsExitCodeAsTerminalBeforeActiveFallback() throws {
- var projection = ReviewMonitorTimelineLogProjection()
- let document = ReviewTimelineDocument(
- timelineRevision: .init(rawValue: 1),
- orderedBlockIDs: ["cmd-active-exited"],
- activeBlockIDs: ["cmd-active-exited"],
- activeBlockCount: 1,
- latestActivityBlockID: "cmd-active-exited",
- terminalStatus: nil,
- terminalSummary: nil,
- terminalResult: nil,
- blocks: [
- .init(
- id: "cmd-active-exited",
- sourceItemID: "cmd-active-exited",
- kind: .commandExecution,
- family: .command,
- phase: .running,
- isActive: true,
- primaryText: "Running swift test",
- rawTranscriptText: "$ swift test\nTests failed",
- content: .command(.init(
- title: "Command",
- command: "swift test",
- output: "Tests failed",
- exitCode: 1
- )),
- createdAt: Date(timeIntervalSince1970: 400),
- updatedAt: Date(timeIntervalSince1970: 400)
- ),
- ]
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
)
- let sourceLog = projection.render(timelineDocument: document)
- let renderedLog = ReviewMonitorCommandOutputDisplayDocument.make(from: sourceLog)
- let panel = try #require(renderedLog.commandOutputPanels.first)
- let metadata = try #require(sourceLog.blocks.first?.metadata)
-
- #expect(panel.isActive == false)
- #expect(panel.title == "Ran swift test")
- #expect(panel.exitText == "exit 1")
- #expect(metadata.status == "failed")
- #expect(metadata.commandStatus == "failed")
- }
-
- @Test func timelineProjectionMarksInactiveRunningCommandCompleted() throws {
- var projection = ReviewMonitorTimelineLogProjection()
- let document = ReviewTimelineDocument(
- timelineRevision: .init(rawValue: 1),
- orderedBlockIDs: ["cmd-inactive-running"],
- activeBlockIDs: [],
- activeBlockCount: 0,
- latestActivityBlockID: "cmd-inactive-running",
- terminalStatus: nil,
- terminalSummary: nil,
- terminalResult: nil,
- blocks: [
- .init(
- id: "cmd-inactive-running",
- sourceItemID: "cmd-inactive-running",
- kind: .commandExecution,
- family: .command,
- phase: .running,
- isActive: false,
- primaryText: "Running swift test",
- rawTranscriptText: "$ swift test",
- content: .command(.init(
- title: "Command",
- command: "swift test",
- status: .inProgress
- )),
- createdAt: Date(timeIntervalSince1970: 400),
- updatedAt: Date(timeIntervalSince1970: 400)
- ),
- ]
+ await appendChatLogEntryForTesting(
+ .init(
+ kind: .commandOutput,
+ groupID: "cmd-running-direct",
+ text: "Building...",
+ metadata: .init(command: "swift test", commandStatus: "running")
+ ),
+ to: chat.chatID,
+ turnID: chat.turnID
)
- let sourceLog = projection.render(timelineDocument: document)
- let renderedLog = ReviewMonitorCommandOutputDisplayDocument.make(from: sourceLog)
- let panel = try #require(renderedLog.commandOutputPanels.first)
- let metadata = try #require(sourceLog.blocks.first?.metadata)
+ let snapshot = try await awaitChatRenderForTesting(chat, in: transport) {
+ $0.log.contains("Running swift test")
+ }
+ #expect(snapshot.log.contains("Running swift test"))
+ #expect(snapshot.log.contains("Ran swift test") == false)
+ #expect(snapshot.log.contains("Building...") == false)
+ #expect(transport.logCommandOutputPanelCountForTesting == 1)
- #expect(panel.isActive == false)
- #expect(panel.title == "Ran swift test")
- #expect(metadata.status == "completed")
- #expect(metadata.commandStatus == "completed")
+ let panelBlockID = chatCommandOutputBlockIDForTesting(turnID: chat.turnID, itemID: "cmd-running-direct")
+ #expect(transport.clickLogCommandOutputPanelHeaderForTesting(blockID: panelBlockID))
+ await awaitNativeLayoutTurn()
+ #expect(transport.logCommandOutputPanelResultTextForTesting == "running")
+ #expect(
+ transport.logCommandOutputPanelTerminalTextForTesting(blockID: panelBlockID)?.contains("Building...")
+ == true)
}
- @Test func directTimelineFileChangePreservesPanelTitle() async throws {
- let job = CodexReviewJob.makeForTesting(
- id: "job-direct-timeline-file-change",
+ @Test func selectedChatFileChangePreservesPanelTitle() async throws {
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-selected-chat-file-change",
cwd: "/tmp/workspace-alpha",
- targetSummary: "Uncommitted changes",
- threadID: UUID().uuidString,
- turnID: UUID().uuidString,
+ title: "Uncommitted changes",
+ turnID: CodexTurnID(rawValue: "turn-selected-chat-file-change"),
status: .running,
startedAt: Date(timeIntervalSince1970: 200),
- summary: "Running review.",
- logEntries: []
+ chatEntries: []
)
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
- content: makeSidebarContent(from: [job])
+ fixtures: [chat]
)
let backend = makeWindowHarness(
store: store,
@@ -3795,23 +1108,28 @@ struct ReviewUITests {
let window = backend.window
defer { window.close() }
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
-
- job.timeline.apply(.itemCompleted(.init(
- id: "file-change-direct",
- kind: .fileChange,
- family: .fileChange,
- phase: .completed,
- content: .fileChange(.init(
- title: "Updated Sources/App.swift",
- output: "Sources/App.swift | 12 ++++++------",
- paths: ["Sources/App.swift"],
- status: .started
- ))
- )))
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
+
+ await appendChatLogEntryForTesting(
+ .init(
+ kind: .fileChange,
+ groupID: "file-change-direct",
+ text: "Sources/App.swift | 12 ++++++------",
+ metadata: .init(
+ title: "Updated Sources/App.swift",
+ commandStatus: "completed"
+ )
+ ),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
- let snapshot = try await awaitTransportRender(transport) {
+ let snapshot = try await awaitChatRenderForTesting(chat, in: transport) {
$0.log.contains("Updated Sources/App.swift")
}
#expect(snapshot.log.contains("Updated Sources/App.swift"))
@@ -3819,7 +1137,7 @@ struct ReviewUITests {
#expect(snapshot.log.contains("Sources/App.swift | 12") == false)
#expect(transport.logCommandOutputPanelCountForTesting == 1)
- let panelBlockID = ReviewMonitorLog.BlockID("commandOutput:file-change-direct")
+ let panelBlockID = chatCommandOutputBlockIDForTesting(turnID: chat.turnID, itemID: "file-change-direct", kind: .fileChange)
#expect(transport.clickLogCommandOutputPanelHeaderForTesting(blockID: panelBlockID))
await awaitNativeLayoutTurn()
#expect(transport.logCommandOutputPanelResultTextForTesting == "Success")
@@ -3830,16 +1148,14 @@ struct ReviewUITests {
}
@Test func contextCompactionMarkerRendersAsVisibleLogTextWithoutCommandPanel() async throws {
- let job = CodexReviewJob.makeForTesting(
- id: "job-context-compaction-marker",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-context-compaction-marker",
cwd: "/tmp/workspace-alpha",
- targetSummary: "Uncommitted changes",
- threadID: UUID().uuidString,
- turnID: UUID().uuidString,
+ title: "Uncommitted changes",
+ turnID: CodexTurnID(rawValue: "turn-context-compaction-marker"),
status: .running,
startedAt: Date(timeIntervalSince1970: 200),
- summary: "Running review.",
- logEntries: [
+ chatEntries: [
.init(
kind: .contextCompaction,
groupID: "compact_1",
@@ -3850,13 +1166,13 @@ struct ReviewUITests {
status: "inProgress",
itemID: "compact_1"
)
- ),
+ )
]
)
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
- content: makeSidebarContent(from: [job])
+ fixtures: [chat]
)
let backend = makeWindowHarness(
store: store,
@@ -3866,25 +1182,33 @@ struct ReviewUITests {
let window = backend.window
defer { window.close() }
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
- _ = try await awaitTransportRender(transport)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
#expect(transport.displayedLogForTesting == "Automatically compacting context")
#expect(transport.logFindStringForTesting.contains("Automatically compacting context"))
#expect(transport.logCommandOutputPanelCountForTesting == 0)
- job.appendLogEntry(.init(
- kind: .contextCompaction,
- groupID: "compact_1",
- replacesGroup: true,
- text: "Context automatically compacted",
- metadata: .init(
- sourceType: "contextCompaction",
- status: "completed",
- itemID: "compact_1"
- )
- ))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(
+ kind: .contextCompaction,
+ groupID: "compact_1",
+ replacesGroup: true,
+ text: "Context automatically compacted",
+ metadata: .init(
+ sourceType: "contextCompaction",
+ status: "completed",
+ itemID: "compact_1"
+ )
+ ),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
#expect(transport.displayedLogForTesting == "Context automatically compacted")
#expect(transport.displayedLogForTesting.contains("Automatically compacting context") == false)
@@ -3896,22 +1220,21 @@ struct ReviewUITests {
let outputText = (1...9)
.map { "output line \($0)" }
.joined(separator: "\n")
- let commandMetadata = ReviewLogEntry.Metadata(
+ let commandMetadata = ReviewChatLogEntryForTesting.Metadata(
sourceType: "command",
title: "Ran command for 17s",
status: "succeeded",
- exitCode: 0
+ command: "swift test",
+ exitCode: 0,
+ commandStatus: "completed"
)
- let job = CodexReviewJob.makeForTesting(
- id: "job-command-output-panel",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-command-output-panel",
cwd: "/tmp/workspace-alpha",
- targetSummary: "Uncommitted changes",
- threadID: UUID().uuidString,
- turnID: UUID().uuidString,
+ title: "Uncommitted changes",
status: .running,
startedAt: Date(timeIntervalSince1970: 200),
- summary: "Running review.",
- logEntries: [
+ chatEntries: [
.init(kind: .command, groupID: "cmd_1", text: "$ swift test"),
.init(
kind: .commandOutput,
@@ -3919,13 +1242,13 @@ struct ReviewUITests {
text: outputText,
metadata: commandMetadata
),
- .init(kind: .agentMessage, text: "Continuing after the command.")
+ .init(kind: .agentMessage, text: "Continuing after the command."),
]
)
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
- content: makeSidebarContent(from: [job])
+ fixtures: [chat]
)
let backend = makeWindowHarness(
store: store,
@@ -3935,9 +1258,13 @@ struct ReviewUITests {
let window = backend.window
defer { window.close() }
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
- _ = try await awaitTransportRender(transport)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
#expect(transport.displayedLogForTesting.contains("Ran swift test"))
#expect(transport.displayedLogForTesting.contains("Ran swift test - 9 lines") == false)
#expect(transport.displayedLogForTesting.contains("$ swift test") == false)
@@ -3985,20 +1312,27 @@ struct ReviewUITests {
#expect(transport.logFindStringForTesting.contains("$ swift test") == false)
#expect(transport.logFindStringForTesting.contains("output line 3") == false)
#expect(transport.logCommandOutputPanelOutputScrollIsScrollableForTesting)
- let initialOutputScrollOffset = try #require(transport.logCommandOutputPanelOutputScrollVerticalOffsetForTesting)
- let initialOutputScrollMaximumOffset = try #require(transport.logCommandOutputPanelOutputScrollMaximumVerticalOffsetForTesting)
+ let initialOutputScrollOffset = try #require(
+ transport.logCommandOutputPanelOutputScrollVerticalOffsetForTesting)
+ let initialOutputScrollMaximumOffset = try #require(
+ transport.logCommandOutputPanelOutputScrollMaximumVerticalOffsetForTesting)
#expect(abs(initialOutputScrollOffset - initialOutputScrollMaximumOffset) <= 0.5)
#expect(transport.scrollCommandOutputPanelOutputForTesting(deltaY: -24))
- let scrolledOutputScrollOffset = try #require(transport.logCommandOutputPanelOutputScrollVerticalOffsetForTesting)
+ let scrolledOutputScrollOffset = try #require(
+ transport.logCommandOutputPanelOutputScrollVerticalOffsetForTesting)
#expect(scrolledOutputScrollOffset < initialOutputScrollMaximumOffset)
let expandedOutputAppendReloadCount = transport.logReloadCountForTesting
- job.appendLogEntry(.init(
- kind: .commandOutput,
- groupID: "cmd_1",
- text: "\noutput line 10",
- metadata: commandMetadata
- ))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(
+ kind: .commandOutput,
+ groupID: "cmd_1",
+ text: "\noutput line 10",
+ metadata: commandMetadata
+ ),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
await awaitNativeLayoutTurn()
#expect(transport.logReloadCountForTesting == expandedOutputAppendReloadCount)
let offsetAfterOutputAppend = try #require(transport.logCommandOutputPanelOutputScrollVerticalOffsetForTesting)
@@ -4015,8 +1349,10 @@ struct ReviewUITests {
#expect(transport.logReloadCountForTesting == reopenReloadCount)
#expect(transport.logExpandedCommandOutputPanelCountForTesting == 1)
await awaitNativeLayoutTurn()
- let reopenedOutputScrollOffset = try #require(transport.logCommandOutputPanelOutputScrollVerticalOffsetForTesting)
- let reopenedOutputScrollMaximumOffset = try #require(transport.logCommandOutputPanelOutputScrollMaximumVerticalOffsetForTesting)
+ let reopenedOutputScrollOffset = try #require(
+ transport.logCommandOutputPanelOutputScrollVerticalOffsetForTesting)
+ let reopenedOutputScrollMaximumOffset = try #require(
+ transport.logCommandOutputPanelOutputScrollMaximumVerticalOffsetForTesting)
#expect(abs(reopenedOutputScrollOffset - reopenedOutputScrollMaximumOffset) <= 0.5)
#expect(transport.logCommandOutputPanelTerminalTextForTesting?.contains("$ swift test") == true)
#expect(transport.logCommandOutputPanelTerminalTextForTesting?.contains("output line 1") == true)
@@ -4024,14 +1360,22 @@ struct ReviewUITests {
#expect(transport.displayedLogForTesting.contains("output line 9") == false)
#expect(transport.logFindStringForTesting.contains("output line 9") == false)
- job.appendLogEntry(.init(
- kind: .commandOutput,
- groupID: "cmd_1",
- text: "\noutput line 11",
- metadata: commandMetadata
- ))
- job.appendLogEntry(.init(kind: .agentMessage, text: "Visible text after command output."))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(
+ kind: .commandOutput,
+ groupID: "cmd_1",
+ text: "\noutput line 11",
+ metadata: commandMetadata
+ ),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ await appendChatLogEntryForTesting(
+ .init(kind: .agentMessage, text: "Visible text after command output."),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
await awaitNativeLayoutTurn()
#expect(transport.logCommandOutputPanelTerminalTextForTesting?.contains("output line 11") == true)
#expect(transport.logFindStringForTesting.contains("output line 11") == false)
@@ -4045,16 +1389,13 @@ struct ReviewUITests {
let secondOutput = (1...80)
.map { "second output line \($0)" }
.joined(separator: "\n")
- let job = CodexReviewJob.makeForTesting(
- id: "job-command-output-panel-isolation",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-command-output-panel-isolation",
cwd: "/tmp/workspace-alpha",
- targetSummary: "Uncommitted changes",
- threadID: UUID().uuidString,
- turnID: UUID().uuidString,
+ title: "Uncommitted changes",
status: .running,
startedAt: Date(timeIntervalSince1970: 200),
- summary: "Running review.",
- logEntries: [
+ chatEntries: [
.init(kind: .command, groupID: "cmd_1", text: "$ swift test"),
.init(
kind: .commandOutput,
@@ -4074,7 +1415,7 @@ struct ReviewUITests {
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
- content: makeSidebarContent(from: [job])
+ fixtures: [chat]
)
let backend = makeWindowHarness(
store: store,
@@ -4084,45 +1425,49 @@ struct ReviewUITests {
let window = backend.window
defer { window.close() }
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
- _ = try await awaitTransportRender(transport)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
- let firstBlockID = ReviewMonitorLog.BlockID("commandOutput:cmd_1")
- let secondBlockID = ReviewMonitorLog.BlockID("commandOutput:cmd_2")
+ let firstBlockID = chatCommandOutputBlockIDForTesting(turnID: chat.turnID, itemID: "cmd_1")
+ let secondBlockID = chatCommandOutputBlockIDForTesting(turnID: chat.turnID, itemID: "cmd_2")
#expect(transport.clickLogCommandOutputPanelHeaderForTesting(blockID: firstBlockID))
await awaitNativeLayoutTurn()
- #expect(transport.logCommandOutputPanelTerminalTextForTesting(blockID: firstBlockID)?
- .contains("first output line 80") == true)
+ #expect(
+ transport.logCommandOutputPanelTerminalTextForTesting(blockID: firstBlockID)?
+ .contains("first output line 80") == true)
#expect(transport.clickLogCommandOutputPanelHeaderForTesting(blockID: secondBlockID))
await awaitNativeLayoutTurn()
- #expect(transport.logCommandOutputPanelTerminalTextForTesting(blockID: firstBlockID)?
- .contains("first output line 80") == true)
- #expect(transport.logCommandOutputPanelTerminalTextForTesting(blockID: secondBlockID)?
- .contains("second output line 80") == true)
+ #expect(
+ transport.logCommandOutputPanelTerminalTextForTesting(blockID: firstBlockID)?
+ .contains("first output line 80") == true)
+ #expect(
+ transport.logCommandOutputPanelTerminalTextForTesting(blockID: secondBlockID)?
+ .contains("second output line 80") == true)
}
@Test func startedCommandRendersAsCollapsedPanelBeforeOutputArrives() async throws {
- let job = CodexReviewJob.makeForTesting(
- id: "job-command-start-panel",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-command-start-panel",
cwd: "/tmp/workspace-alpha",
- targetSummary: "Uncommitted changes",
- threadID: UUID().uuidString,
- turnID: UUID().uuidString,
+ title: "Uncommitted changes",
status: .running,
startedAt: Date(timeIntervalSince1970: 200),
- summary: "Running review.",
- logEntries: [
+ chatEntries: [
.init(kind: .command, groupID: "cmd_1", text: "$ swift test")
]
)
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
- content: makeSidebarContent(from: [job])
+ fixtures: [chat]
)
let backend = makeWindowHarness(
store: store,
@@ -4132,20 +1477,35 @@ struct ReviewUITests {
let window = backend.window
defer { window.close() }
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
- _ = try await awaitTransportRender(transport)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
#expect(transport.logCommandOutputPanelCountForTesting == 1)
#expect(transport.displayedLogForTesting.contains("Running swift test"))
#expect(transport.displayedLogForTesting.contains("$ swift test") == false)
- job.appendLogEntry(.init(
- kind: .commandOutput,
- groupID: "cmd_1",
- text: "output line 1",
- metadata: .init(sourceType: "commandExecution", title: "Command output", status: "succeeded", exitCode: 0)
- ))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(
+ kind: .commandOutput,
+ groupID: "cmd_1",
+ text: "output line 1",
+ metadata: .init(
+ sourceType: "commandExecution",
+ title: "Command output",
+ status: "succeeded",
+ command: "swift test",
+ exitCode: 0,
+ commandStatus: "completed"
+ )
+ ),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
#expect(transport.logCommandOutputPanelCountForTesting == 1)
#expect(transport.displayedLogForTesting.contains("Ran swift test"))
#expect(transport.displayedLogForTesting.contains("$ swift test") == false)
@@ -4156,24 +1516,21 @@ struct ReviewUITests {
let outputText = (1...5)
.map { "output line \($0)" }
.joined(separator: "\n")
- let job = CodexReviewJob.makeForTesting(
- id: "job-command-output-find-refresh",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-command-output-find-refresh",
cwd: "/tmp/workspace-alpha",
- targetSummary: "Uncommitted changes",
- threadID: UUID().uuidString,
- turnID: UUID().uuidString,
+ title: "Uncommitted changes",
status: .running,
startedAt: Date(timeIntervalSince1970: 200),
- summary: "Running review.",
- logEntries: [
+ chatEntries: [
.init(kind: .command, groupID: "cmd_1", text: "$ swift test"),
- .init(kind: .commandOutput, groupID: "cmd_1", text: outputText)
+ .init(kind: .commandOutput, groupID: "cmd_1", text: outputText),
]
)
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
- content: makeSidebarContent(from: [job])
+ fixtures: [chat]
)
let backend = makeWindowHarness(
store: store,
@@ -4183,8 +1540,12 @@ struct ReviewUITests {
let window = backend.window
defer { window.close() }
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
try await withFindPasteboardString(nil) {
viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface))
@@ -4205,8 +1566,12 @@ struct ReviewUITests {
#expect(transport.logFindStringForTesting.contains("$ swift test") == false)
#expect(transport.logFindStringForTesting.contains("output line 3") == false)
- job.appendLogEntry(.init(kind: .commandOutput, groupID: "cmd_1", text: "\noutput line 6"))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(kind: .commandOutput, groupID: "cmd_1", text: "\noutput line 6"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
await awaitNativeLayoutTurn()
#expect(transport.logFindClientUsesSnapshotForTesting)
@@ -4216,16 +1581,16 @@ struct ReviewUITests {
}
}
- @Test func switchingSelectedJobRebindsDetailPane() async throws {
- let activeJob = makeJob(
- id: "job-active",
+ @Test func switchingSelectedReviewChatRebindsDetailPane() async throws {
+ let activeChat = makeReviewChatFixtureForTesting(
+ id: "chat-active",
status: .running,
targetSummary: "Uncommitted changes",
summary: "Active review in progress.",
logText: "Active log\n"
)
- let recentJob = makeJob(
- id: "job-recent",
+ let recentChat = makeReviewChatFixtureForTesting(
+ id: "chat-recent",
status: .succeeded,
targetSummary: "Commit: abc123",
summary: "Recent review completed.",
@@ -4234,71 +1599,77 @@ struct ReviewUITests {
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
- content: makeSidebarContent(from: [activeJob, recentJob])
+ fixtures: [activeChat, recentChat]
)
let backend = makeWindowHarness(store: store)
let viewController = backend.viewController
let window = backend.window
defer { window.close() }
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(activeJob)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: activeChat.chatID)
- let activeSnapshot = try await awaitTransportRender(transport)
- #expect(activeSnapshot.title == nil)
- #expect(activeSnapshot.summary == nil)
- #expect(window.title == activeJob.targetSummary)
- #expect(window.subtitle == activeJob.cwd)
- viewController.sidebarViewControllerForTesting.selectJobForTesting(recentJob)
+ _ = try await awaitChatRenderForTesting(
+ activeChat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: recentChat.chatID)
- let recentSnapshot = try await awaitTransportRender(transport)
+ let recentSnapshot = try await awaitChatRenderForTesting(
+ recentChat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
#expect(
- recentSnapshot == .init(
- title: nil,
- summary: nil,
- log: recentJob.logText,
- isShowingEmptyState: false
- )
+ recentSnapshot
+ == .init(
+ log: reviewChatLogText(for: recentChat),
+ isShowingEmptyState: false
+ )
)
- #expect(window.title == recentJob.targetSummary)
- #expect(window.subtitle == recentJob.cwd)
}
- @Test func firstSelectionFromEmptyStatePinsUnvisitedJobToBottom() async throws {
+ @Test func firstSelectionFromEmptyStatePinsUnvisitedReviewChatToBottom() async throws {
let longLog = (0..<400).map { "line \($0)" }.joined(separator: "\n")
- let job = makeJob(
- id: "job-first-bottom",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-first-bottom",
status: .running,
targetSummary: "Uncommitted changes",
summary: "Running review.",
logText: longLog
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 900, height: 600))
viewController.loadViewIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
transport.view.layoutSubtreeIfNeeded()
- #expect(transport.isLogPinnedToBottomForTesting)
+ try await waitForLogPinnedToBottom(in: transport)
}
- @Test func switchingSelectedJobStartsUnvisitedJobAtBottomAndRestoresPreviousOffset() async throws {
+ @Test func switchingSelectedReviewChatStartsUnvisitedReviewChatAtBottomAndRestoresPreviousOffset() async throws {
let longActiveLog = (0..<400).map { "active line \($0)" }.joined(separator: "\n")
let longRecentLog = (0..<400).map { "recent line \($0)" }.joined(separator: "\n")
- let activeJob = makeJob(
- id: "job-active-scroll",
+ let activeChat = makeReviewChatFixtureForTesting(
+ id: "chat-active-scroll",
status: .running,
targetSummary: "Uncommitted changes",
summary: "Active review in progress.",
logText: longActiveLog
)
- let recentJob = makeJob(
- id: "job-recent-scroll",
+ let recentChat = makeReviewChatFixtureForTesting(
+ id: "chat-recent-scroll",
status: .succeeded,
targetSummary: "Commit: abc123",
summary: "Recent review completed.",
@@ -4307,45 +1678,58 @@ struct ReviewUITests {
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
- content: makeSidebarContent(from: [activeJob, recentJob])
+ fixtures: [activeChat, recentChat]
)
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 900, height: 600))
viewController.loadViewIfNeeded()
viewController.view.layoutSubtreeIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(activeJob)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: activeChat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ activeChat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
transport.scrollLogToOffsetForTesting(120)
let activeOffset = transport.logVerticalScrollOffsetForTesting
#expect(activeOffset > 0)
#expect(transport.isLogPinnedToBottomForTesting == false)
- viewController.sidebarViewControllerForTesting.selectJobForTesting(recentJob)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: recentChat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ recentChat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
#expect(transport.isLogPinnedToBottomForTesting)
- viewController.sidebarViewControllerForTesting.selectJobForTesting(activeJob)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: activeChat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ activeChat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
#expect(transport.logVerticalScrollOffsetForTesting == activeOffset)
#expect(transport.isLogPinnedToBottomForTesting == false)
}
- @Test func switchingSelectedJobRestoresPinnedBottomPosition() async throws {
+ @Test func switchingSelectedReviewChatRestoresPinnedBottomPosition() async throws {
let longActiveLog = (0..<400).map { "active line \($0)" }.joined(separator: "\n")
let longRecentLog = (0..<400).map { "recent line \($0)" }.joined(separator: "\n")
- let activeJob = makeJob(
- id: "job-active-bottom",
+ let activeChat = makeReviewChatFixtureForTesting(
+ id: "chat-active-bottom",
status: .running,
targetSummary: "Uncommitted changes",
summary: "Active review in progress.",
logText: longActiveLog
)
- let recentJob = makeJob(
- id: "job-recent-bottom",
+ let recentChat = makeReviewChatFixtureForTesting(
+ id: "chat-recent-bottom",
status: .succeeded,
targetSummary: "Commit: abc123",
summary: "Recent review completed.",
@@ -4354,220 +1738,290 @@ struct ReviewUITests {
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
- content: makeSidebarContent(from: [activeJob, recentJob])
+ fixtures: [activeChat, recentChat]
)
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 900, height: 600))
viewController.loadViewIfNeeded()
viewController.view.layoutSubtreeIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(activeJob)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: activeChat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ activeChat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
transport.scrollLogToBottomForTesting()
#expect(transport.isLogPinnedToBottomForTesting)
- viewController.sidebarViewControllerForTesting.selectJobForTesting(recentJob)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: recentChat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ recentChat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
#expect(transport.isLogPinnedToBottomForTesting)
- activeJob.appendLogEntry(.init(kind: .progress, text: "Newest active line"))
- viewController.sidebarViewControllerForTesting.selectJobForTesting(activeJob)
- let snapshot = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(kind: .progress, text: "Newest active line"),
+ to: activeChat.chatID,
+ turnID: activeChat.turnID
+ )
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: activeChat.chatID)
+ let snapshot = try await awaitChatRenderForTesting(
+ activeChat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
#expect(snapshot.log.contains("Newest active line"))
#expect(transport.isLogPinnedToBottomForTesting)
}
- @Test func rehydratingSameSelectedJobPreservesLogScrollPosition() async throws {
+ @Test func rehydratingSameSelectedReviewChatPreservesLogScrollPosition() async throws {
let longLog = (0..<400).map { "line \($0)" }.joined(separator: "\n")
- let job = makeJob(
- id: "job-rehydrated",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-rehydrated",
status: .running,
targetSummary: "Uncommitted changes",
summary: "Running review.",
logText: longLog
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 900, height: 600))
viewController.loadViewIfNeeded()
viewController.view.layoutSubtreeIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
transport.scrollLogToOffsetForTesting(120)
let preservedOffset = transport.logVerticalScrollOffsetForTesting
#expect(preservedOffset > 0)
- let replacement = makeJob(
- id: "job-rehydrated",
+ let replacement = makeReviewChatFixtureForTesting(
+ id: "chat-rehydrated",
status: .running,
targetSummary: "Uncommitted changes",
summary: "Running review.",
logText: longLog
)
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [replacement]))
+ store.loadForTesting(serverState: .running, fixtures: [replacement])
- #expect(transport.displayedLogForTesting == longLog)
+ #expect(transport.displayedLogForTesting == reviewChatLogText(for: replacement))
#expect(transport.logVerticalScrollOffsetForTesting == preservedOffset)
}
- @Test func switchingJobWithIdenticalLogTextStartsUnvisitedJobAtBottom() async throws {
+ @Test func switchingReviewChatWithIdenticalLogTextStartsUnvisitedReviewChatAtBottom() async throws {
let sharedLog = (0..<400).map { "shared line \($0)" }.joined(separator: "\n")
- let firstJob = makeJob(
- id: "job-identical-1",
+ let firstChat = makeReviewChatFixtureForTesting(
+ id: "chat-identical-1",
status: .running,
targetSummary: "Uncommitted changes",
summary: "Running review.",
logText: sharedLog
)
- let secondJob = makeJob(
- id: "job-identical-2",
+ let secondChat = makeReviewChatFixtureForTesting(
+ id: "chat-identical-2",
status: .succeeded,
targetSummary: "Commit: abc123",
summary: "Review completed.",
logText: sharedLog
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [firstJob, secondJob]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [firstChat, secondChat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 900, height: 600))
viewController.loadViewIfNeeded()
viewController.view.layoutSubtreeIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(firstJob)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: firstChat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ firstChat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
transport.scrollLogToOffsetForTesting(120)
#expect(transport.logVerticalScrollOffsetForTesting > 0)
- viewController.sidebarViewControllerForTesting.selectJobForTesting(secondJob)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: secondChat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ secondChat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
- #expect(transport.isLogPinnedToBottomForTesting)
+ try await waitForLogPinnedToBottom(in: transport)
}
- @Test func switchingFromShortToLongJobMaterializesVisibleTextKit2Fragments() async throws {
+ @Test func switchingFromShortToLongChatMaterializesVisibleTextKit2Fragments() async throws {
let shortLog = (0..<3).map { "short visible line \($0)" }.joined(separator: "\n")
let longLog = (0..<700)
.map { "long visible fragment line \($0) with enough text to exercise viewport surface reuse" }
.joined(separator: "\n")
- let shortJob = makeJob(
- id: "job-fragment-short",
+ let shortChat = makeReviewChatFixtureForTesting(
+ id: "chat-fragment-short",
status: .running,
targetSummary: "Short log",
summary: "Short preview.",
logText: shortLog
)
- let longJob = makeJob(
- id: "job-fragment-long",
+ let longChat = makeReviewChatFixtureForTesting(
+ id: "chat-fragment-long",
status: .succeeded,
targetSummary: "Long log",
summary: "Long review completed.",
logText: longLog
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [shortJob, longJob]))
+ store.loadForTesting(serverState: .running, fixtures: [shortChat, longChat])
let harness = makeWindowHarness(store: store, contentSize: NSSize(width: 900, height: 600))
let viewController = harness.viewController
let window = harness.window
defer { window.close() }
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(shortJob)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: shortChat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ shortChat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
- viewController.sidebarViewControllerForTesting.selectJobForTesting(longJob)
- let longSnapshot = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: longChat.chatID)
+ let longSnapshot = try await awaitChatRenderForTesting(
+ longChat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
- #expect(longSnapshot.log == longLog)
+ #expect(longSnapshot.log == reviewChatLogText(for: longChat))
#expect(transport.isLogPinnedToBottomForTesting)
expectLogVisibleFragmentsWithoutForcingLayout(transport)
}
- @Test func shortLogSelectionCacheRestoresTopAfterLaterGrowth() async throws {
+ @Test func shortLogSelectionAutoFollowsAfterLaterGrowth() async throws {
let shortLog = (0..<3).map { "short line \($0)" }.joined(separator: "\n")
let longLog = (0..<400).map { "long line \($0)" }.joined(separator: "\n")
- let shortJob = makeJob(
- id: "job-short-cache",
+ let shortChat = makeReviewChatFixtureForTesting(
+ id: "chat-short-cache",
status: .running,
targetSummary: "Uncommitted changes",
summary: "Short preview.",
logText: shortLog
)
- let recentJob = makeJob(
- id: "job-short-cache-recent",
+ let recentChat = makeReviewChatFixtureForTesting(
+ id: "chat-short-cache-recent",
status: .succeeded,
targetSummary: "Commit: abc123",
summary: "Recent review completed.",
logText: longLog
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [shortJob, recentJob]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [shortChat, recentChat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 900, height: 600))
viewController.loadViewIfNeeded()
viewController.view.layoutSubtreeIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(shortJob)
- _ = try await awaitTransportRender(transport)
- viewController.sidebarViewControllerForTesting.selectJobForTesting(recentJob)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: shortChat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ shortChat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: recentChat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ recentChat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
expectLogVisibleFragmentsWithoutForcingLayout(transport)
- shortJob.replaceLogEntries([.init(kind: .agentMessage, text: longLog)])
- viewController.sidebarViewControllerForTesting.selectJobForTesting(shortJob)
- _ = try await awaitTransportRender(transport)
+ await replaceChatLogTextForTesting(
+ longLog,
+ for: shortChat.chatID,
+ fixtureID: shortChat.id,
+ turnID: shortChat.turnID
+ )
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: shortChat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ shortChat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
- #expect(abs(
- transport.logVerticalScrollOffsetForTesting
- - transport.logMinimumVerticalScrollOffsetForTesting
- ) < 0.5)
- #expect(transport.isLogPinnedToBottomForTesting == false)
+ #expect(transport.isLogPinnedToBottomForTesting)
expectLogVisibleFragmentsWithoutForcingLayout(transport)
}
- @Test func previouslySelectedJobUpdatesDoNotRepaintCurrentDetailPane() async throws {
- let activeJob = makeJob(
- id: "job-old-selection",
+ @Test func previouslySelectedReviewChatUpdatesDoNotRepaintCurrentDetailPane() async throws {
+ let activeChat = makeReviewChatFixtureForTesting(
+ id: "chat-old-selection",
status: .running,
targetSummary: "Uncommitted changes",
summary: "Active review.",
logText: "Active log\n"
)
- let recentJob = makeJob(
- id: "job-current-selection",
+ let recentChat = makeReviewChatFixtureForTesting(
+ id: "chat-current-selection",
status: .succeeded,
targetSummary: "Commit: abc123",
summary: "Recent review.",
logText: "Recent log\n"
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [activeJob, recentJob]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [activeChat, recentChat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 900, height: 600))
viewController.loadViewIfNeeded()
viewController.view.layoutSubtreeIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(activeJob)
- _ = try await awaitTransportRender(transport)
- viewController.sidebarViewControllerForTesting.selectJobForTesting(recentJob)
- _ = try await awaitTransportRender(transport)
- activeJob.appendLogEntry(.init(kind: .progress, text: "stale update"))
- recentJob.appendLogEntry(.init(kind: .progress, text: "fresh update"))
-
- let updatedSnapshot = try await awaitTransportRender(transport) { snapshot in
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: activeChat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ activeChat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: recentChat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ recentChat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
+ await appendChatLogEntryForTesting(
+ .init(kind: .progress, text: "stale update"),
+ to: activeChat.chatID,
+ turnID: activeChat.turnID
+ )
+ await appendChatLogEntryForTesting(
+ .init(kind: .progress, text: "fresh update"),
+ to: recentChat.chatID,
+ turnID: recentChat.turnID
+ )
+
+ let updatedSnapshot = try await awaitChatRenderForTesting(recentChat, in: transport) { snapshot in
snapshot.log.contains("fresh update")
}
#expect(updatedSnapshot.log.contains("stale update") == false)
@@ -4575,8 +2029,8 @@ struct ReviewUITests {
}
@Test func clickingSidebarBlankAreaKeepsSelectionAndDetailPane() async throws {
- let job = makeJob(
- id: "job-selected",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-selected",
status: .running,
targetSummary: "Uncommitted changes",
summary: "Review is still running.",
@@ -4585,135 +2039,166 @@ struct ReviewUITests {
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
- content: makeSidebarContent(from: [job])
+ fixtures: [chat]
)
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 900, height: 600))
viewController.loadViewIfNeeded()
viewController.view.layoutSubtreeIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
- let selectedSnapshot = try await awaitTransportRender(transport)
+ let selectedSnapshot = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
viewController.sidebarViewControllerForTesting.clickBlankAreaForTesting()
- #expect(viewController.sidebarViewControllerForTesting.selectedJobForTesting?.id == job.id)
+ #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == chat.chatID)
#expect(transport.renderSnapshotForTesting == selectedSnapshot)
}
- @Test func clickingWorkspaceHeaderSelectsWorkspaceAndShowsFindingsPane() async throws {
- let job = makeJob(
- id: "job-selected",
+ @Test func clickingWorkspaceHeaderSelectsWorkspacePlaceholder() async throws {
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-selected",
cwd: "/tmp/workspace-alpha",
status: .running,
targetSummary: "Uncommitted changes",
summary: "Review is still running.",
logText: "Selected log\n"
)
- let workspace = CodexReviewWorkspace(cwd: job.cwd)
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
- workspaces: [workspace],
- jobs: [job]
+ fixtures: [chat]
)
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 900, height: 600))
viewController.loadViewIfNeeded()
viewController.view.layoutSubtreeIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
- _ = try await awaitTransportRender(transport)
- viewController.sidebarViewControllerForTesting.clickWorkspaceHeaderForTesting(workspace)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
+ let expectedWorkspaceGroupID = try #require(
+ viewController.sidebarViewControllerForTesting.workspaceGroupIDForTesting(cwd: chat.cwd)
+ )
+ viewController.sidebarViewControllerForTesting.clickWorkspaceHeaderForTesting(cwd: chat.cwd)
- _ = try await awaitTransportRender(transport)
- #expect(viewController.sidebarViewControllerForTesting.selectedWorkspaceSectionForTesting?.workspaceCWDs == [workspace.cwd])
- #expect(viewController.sidebarViewControllerForTesting.selectedJobForTesting == nil)
- #expect(
- transport.workspaceFindingSnapshotForTesting == .init(
- text: "",
- isShowingNoFindingsState: true,
- isShowingFindingsList: false
- )
+ _ = try await awaitTransportRender(
+ transport,
+ expectedSelection: .workspaceGroup(expectedWorkspaceGroupID.rawValue)
)
+ #expect(
+ viewController.sidebarViewControllerForTesting.selectedWorkspaceGroupIDForTesting
+ == expectedWorkspaceGroupID)
+ #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == nil)
+ #expect(transport.isShowingNoFindingsStateForTesting)
}
- @Test func newJobsArrivingWhileUnselectedDoNotAutoSelect() {
- let activeJob = makeJob(status: .running, targetSummary: "Uncommitted changes")
+ @Test func newChatsArrivingWhileUnselectedDoNotAutoSelect() {
+ let activeChat = makeReviewChatFixtureForTesting(status: .running, targetSummary: "Uncommitted changes")
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, workspaces: [])
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running)
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
viewController.loadViewIfNeeded()
- #expect(viewController.sidebarViewControllerForTesting.selectedJobForTesting == nil)
+ #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == nil)
#expect(viewController.contentPaneViewControllerForTesting.isShowingEmptyStateForTesting)
store.loadForTesting(
serverState: .running,
- content: makeSidebarContent(from: [activeJob])
+ fixtures: [activeChat]
)
- #expect(viewController.sidebarViewControllerForTesting.selectedJobForTesting == nil)
+ #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == nil)
#expect(viewController.contentPaneViewControllerForTesting.isShowingEmptyStateForTesting)
- #expect(viewController.contentPaneViewControllerForTesting.displayedTitleForTesting == nil)
}
- @Test func removingSelectedJobClearsSelectionWithoutAutoSelectingReplacement() async throws {
- let activeJob = makeJob(
- id: "job-active",
- status: .running,
- targetSummary: "Uncommitted changes",
- summary: "Active review in progress.",
- logText: "Active log\n"
- )
- let recentJob = makeJob(
- id: "job-recent",
- status: .succeeded,
- targetSummary: "Commit: abc123",
- summary: "Recent review completed.",
- logText: "Recent log\n"
+ @Test func removingSelectedReviewChatClearsSelectionWithoutAutoSelectingReplacement() async throws {
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let context = CodexModelContainer(appServer: runtime.server).mainContext
+ let repo = FileManager.default.temporaryDirectory
+ .appendingPathComponent("ReviewUITests-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: repo, withIntermediateDirectories: true)
+ try FileManager.default.createDirectory(
+ at: repo.appendingPathComponent(".git", isDirectory: true),
+ withIntermediateDirectories: true
)
+ let activeThreadID = CodexThreadID(rawValue: "thread-active")
+ let recentThreadID = CodexThreadID(rawValue: "thread-recent")
+
+ try await runtime.transport.enqueueThreadList(
+ .init(threads: [
+ .init(
+ id: activeThreadID,
+ workspace: repo,
+ name: "Uncommitted changes",
+ updatedAt: Date(timeIntervalSince1970: 5_000)
+ ),
+ .init(
+ id: recentThreadID,
+ workspace: repo,
+ name: "Commit: abc123",
+ updatedAt: Date(timeIntervalSince1970: 4_000)
+ ),
+ ]))
+
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(
- serverState: .running,
- content: makeSidebarContent(from: [activeJob, recentJob])
+ store.loadForTesting(serverState: .running)
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store,
+ uiState: ReviewMonitorUIState(auth: store.auth),
+ modelContext: context
)
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
viewController.loadViewIfNeeded()
let contentPane = viewController.contentPaneViewControllerForTesting
- let transport = viewController.transportViewControllerForTesting
let sidebar = viewController.sidebarViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(activeJob)
- let activeSnapshot = try await awaitTransportRender(transport)
- #expect(activeSnapshot.title == nil)
- #expect(activeSnapshot.summary == nil)
- store.loadForTesting(
- serverState: .running,
- content: makeSidebarContent(from: [recentJob])
- )
- try await waitForObservedValue(
- from: sidebar.sidebarTopologyObservationForTesting,
- true
- ) {
- sidebar.selectedJobForTesting == nil
+ try await waitForCondition {
+ sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(activeThreadID)) == "Uncommitted changes"
+ && sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(recentThreadID)) == "Commit: abc123"
+ }
+ sidebar.selectCodexSidebarRowForTesting(rowID: .chat(activeThreadID))
+ try await waitForCondition {
+ sidebar.selectedReviewChatIDForTesting == activeThreadID
+ }
+
+ let activeChat = context.model(for: activeThreadID)
+ try await runtime.transport.enqueueEmpty(for: "thread/delete")
+ try await activeChat.delete()
+
+ try await waitForCondition {
+ sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(activeThreadID)) == nil
+ && sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(recentThreadID)) == "Commit: abc123"
+ && sidebar.selectedReviewChatIDForTesting == nil
}
- let emptySnapshot = try await awaitContentPaneRender(contentPane)
- #expect(sidebar.selectedJobForTesting == nil)
+ let emptySnapshot = try await awaitContentPaneRender(
+ contentPane,
+ expectedSelection: nil
+ ) { snapshot in
+ snapshot.isShowingEmptyState
+ }
+ #expect(sidebar.selectedReviewChatIDForTesting == nil)
#expect(emptySnapshot.isShowingEmptyState)
- #expect(emptySnapshot.title == nil)
- #expect(emptySnapshot.summary == nil)
}
@Test func clearingSelectionShowsEmptyStateAndClearsDetailPane() async throws {
- let job = makeJob(
- id: "job-1",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-1",
status: .running,
targetSummary: "Uncommitted changes",
summary: "Running review.",
@@ -4722,7 +2207,7 @@ struct ReviewUITests {
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
- content: makeSidebarContent(from: [job])
+ fixtures: [chat]
)
let backend = makeWindowHarness(store: store)
let viewController = backend.viewController
@@ -4730,31 +2215,35 @@ struct ReviewUITests {
defer { window.close() }
let contentPane = viewController.contentPaneViewControllerForTesting
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
- let selectedSnapshot = try await awaitTransportRender(transport)
- #expect(selectedSnapshot.title == nil)
- #expect(window.title == job.targetSummary)
- #expect(window.subtitle == job.cwd)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
viewController.sidebarViewControllerForTesting.clearSelectionForTesting()
- let emptySnapshot = try await awaitContentPaneRender(contentPane)
+ let emptySnapshot = try await awaitContentPaneRender(
+ contentPane,
+ expectedSelection: nil
+ )
#expect(emptySnapshot.isShowingEmptyState)
- #expect(emptySnapshot.title == nil)
- #expect(emptySnapshot.summary == nil)
#expect(emptySnapshot.log.isEmpty)
- #expect(window.title == "")
- #expect(window.subtitle == "")
- job.updateStateForTesting(summary: "Deselected summary")
- job.replaceLogEntries([.init(kind: .agentMessage, text: "Deselected log")])
+ await replaceChatLogTextForTesting(
+ "Deselected log",
+ for: chat.chatID,
+ fixtureID: chat.id,
+ turnID: chat.turnID
+ )
- #expect(contentPane.selectedJobObservationForTesting == nil)
+ #expect(contentPane.selectedChatLogTaskForTesting?.isCancelled != false)
#expect(contentPane.renderSnapshotForTesting == emptySnapshot)
}
- @Test func inPlaceJobUpdateKeepsSelectionAndRefreshesDetailPane() async throws {
- let job = makeJob(
- id: "job-1",
+ @Test func inPlaceReviewChatUpdateKeepsSelectionAndRefreshesDetailPane() async throws {
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-1",
status: .running,
targetSummary: "Uncommitted changes",
summary: "Running review.",
@@ -4763,59 +2252,79 @@ struct ReviewUITests {
let store = CodexReviewStore.makePreviewStore()
store.loadForTesting(
serverState: .running,
- content: makeSidebarContent(from: [job])
+ fixtures: [chat]
)
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
viewController.loadViewIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
- let selectedSnapshot = try await awaitTransportRender(transport)
- #expect(selectedSnapshot.title == nil)
- #expect(selectedSnapshot.summary == nil)
- job.updateStateForTesting(
- status: .succeeded,
- summary: "Review completed successfully."
+ let selectedSnapshot = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
+ #expect(reviewChatRenderedLogMatches(selectedSnapshot.log, reviewChatLogText(for: chat)))
+ await replaceChatLogTextForTesting(
+ "Updated log",
+ for: chat.chatID,
+ fixtureID: chat.id,
+ turnID: chat.turnID
)
- job.replaceLogEntries([.init(kind: .agentMessage, text: "Updated log")])
- let updatedSnapshot = try await awaitTransportRender(transport)
- #expect(viewController.sidebarViewControllerForTesting.selectedJobForTesting?.id == "job-1")
- #expect(updatedSnapshot.summary == nil)
- #expect(updatedSnapshot.log == "Updated log")
+ let updatedSnapshot = try await awaitChatRenderForTesting(chat, in: transport)
+ #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == chat.chatID)
+ #expect(reviewChatRenderedLogMatches(updatedSnapshot.log, reviewChatLogText(for: chat)))
}
- @Test func selectedJobLogAppendUsesAppendPath() async throws {
- let job = CodexReviewJob.makeForTesting(
- id: "job-append",
+ @Test func selectedReviewChatLogAppendUsesAppendPath() async throws {
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-append",
cwd: "/tmp/workspace-alpha",
- targetSummary: "Uncommitted changes",
- threadID: UUID().uuidString,
- turnID: UUID().uuidString,
+ title: "Uncommitted changes",
status: .running,
startedAt: Date(timeIntervalSince1970: 200),
- summary: "Running review.",
- logEntries: [
+ chatEntries: [
.init(kind: .agentMessage, groupID: "msg_1", text: "Initial")
]
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
+ let previewRuntime = try #require(previewRuntimeForTesting(on: store))
+ previewRuntime.start()
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store,
+ uiState: ReviewMonitorUIState(auth: store.auth),
+ codexModelSource: previewRuntime.modelSource
+ )
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 900, height: 360))
viewController.loadViewIfNeeded()
viewController.view.layoutSubtreeIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ let chatID = chat.chatID
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatID)
+ _ = try await awaitTransportRender(
+ transport,
+ expectedSelection: .chat(chatID.rawValue)
+ ) { $0.log == "Initial" }
transport.setLogReduceMotionForTesting(false)
let appendCount = transport.logAppendCountForTesting
let reloadCount = transport.logReloadCountForTesting
- job.appendLogEntry(.init(kind: .agentMessage, groupID: "msg_1", text: " log"))
+ await previewRuntime.appendPreviewText(
+ " log",
+ to: chatID,
+ itemID: "msg_1",
+ kind: .agentMessage,
+ content: .message(.init(id: "msg_1", role: .assistant, text: ""))
+ )
- let snapshot = try await awaitTransportRender(transport)
+ let snapshot = try await awaitTransportRender(
+ transport,
+ expectedSelection: .chat(chatID.rawValue)
+ ) { $0.log == "Initial log" }
#expect(snapshot.log == "Initial log")
#expect(transport.logAppendCountForTesting == appendCount + 1)
#expect(transport.logReloadCountForTesting == reloadCount)
@@ -4823,30 +2332,49 @@ struct ReviewUITests {
}
@Test func separatorPrefixedProgressAppendDoesNotUseGenericWordGlow() async throws {
- let job = CodexReviewJob.makeForTesting(
- id: "job-progress-separator-append",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-progress-separator-append",
cwd: "/tmp/workspace-alpha",
- targetSummary: "Uncommitted changes",
- threadID: UUID().uuidString,
- turnID: UUID().uuidString,
+ title: "Uncommitted changes",
status: .running,
startedAt: Date(timeIntervalSince1970: 200),
- summary: "Running review.",
- logEntries: [
+ chatEntries: [
.init(kind: .agentMessage, groupID: "msg_1", text: "Initial")
]
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
+ let previewRuntime = try #require(previewRuntimeForTesting(on: store))
+ previewRuntime.start()
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store,
+ uiState: ReviewMonitorUIState(auth: store.auth),
+ codexModelSource: previewRuntime.modelSource
+ )
+ let window = NSWindow(contentViewController: viewController)
+ defer { window.close() }
+ window.setContentSize(NSSize(width: 900, height: 360))
viewController.loadViewIfNeeded()
+ viewController.view.layoutSubtreeIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ let chatID = chat.chatID
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatID)
+ _ = try await awaitTransportRender(
+ transport,
+ expectedSelection: .chat(chatID.rawValue)
+ ) { $0.log == "Initial" }
let wordGlowCount = transport.logWordGlowCountForTesting
- job.appendLogEntry(.init(kind: .progress, groupID: "progress_1", text: "stream.tick 001"))
+ await previewRuntime.upsertPreviewItem(
+ id: "progress_1",
+ kind: CodexThreadItem.Kind(rawValue: "progress"),
+ content: .diagnostic("stream.tick 001"),
+ to: chatID
+ )
- let snapshot = try await awaitTransportRender(transport)
+ let snapshot = try await awaitTransportRender(
+ transport,
+ expectedSelection: .chat(chatID.rawValue)
+ ) { $0.log.hasSuffix("stream.tick 001") }
#expect(snapshot.log.hasSuffix("stream.tick 001"))
#expect(transport.logWordGlowCountForTesting == wordGlowCount)
}
@@ -4854,26 +2382,28 @@ struct ReviewUITests {
@Test func logCanonicalEquivalentPrefixReloadsWhenUTF16LengthChanges() async throws {
let decomposedPrefix = "Caf\u{0065}\u{0301}"
let precomposedUpdate = "Caf\u{00E9} appended"
- let job = CodexReviewJob.makeForTesting(
- id: "job-canonical-append",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-canonical-append",
cwd: "/tmp/workspace-alpha",
- targetSummary: "Uncommitted changes",
- threadID: UUID().uuidString,
- turnID: UUID().uuidString,
+ title: "Uncommitted changes",
status: .running,
startedAt: Date(timeIntervalSince1970: 200),
- summary: "Running review.",
- logEntries: [
+ chatEntries: [
.init(kind: .agentMessage, groupID: "msg_1", text: decomposedPrefix)
]
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
viewController.loadViewIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
let appendCount = transport.logAppendCountForTesting
let reloadCount = transport.logReloadCountForTesting
@@ -4885,374 +2415,372 @@ struct ReviewUITests {
}
@Test func coalescedLogTextUpdateDisplaysCombinedSuffix() async throws {
- let job = CodexReviewJob.makeForTesting(
- id: "job-coalesced",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-coalesced",
cwd: "/tmp/workspace-alpha",
- targetSummary: "Uncommitted changes",
- threadID: UUID().uuidString,
- turnID: UUID().uuidString,
+ title: "Uncommitted changes",
status: .running,
startedAt: Date(timeIntervalSince1970: 200),
- summary: "Running review.",
- logEntries: [
+ chatEntries: [
.init(kind: .agentMessage, groupID: "msg_1", text: "Initial")
]
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
viewController.loadViewIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
- job.appendLogEntry(.init(kind: .agentMessage, groupID: "msg_1", text: " one"))
- job.appendLogEntry(.init(kind: .agentMessage, groupID: "msg_1", text: " two"))
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
+ await appendChatLogEntryForTesting(
+ .init(kind: .agentMessage, groupID: "msg_1", text: " one"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ await appendChatLogEntryForTesting(
+ .init(kind: .agentMessage, groupID: "msg_1", text: " two"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
- let snapshot = try await awaitTransportRender(transport)
+ let snapshot = try await awaitChatRenderForTesting(chat, in: transport)
#expect(snapshot.log == "Initial one two")
}
@Test func coalescedProgressSuffixDisplaysLatestProgress() async throws {
- let job = CodexReviewJob.makeForTesting(
- id: "job-coalesced-progress",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-coalesced-progress",
cwd: "/tmp/workspace-alpha",
- targetSummary: "Uncommitted changes",
- threadID: UUID().uuidString,
- turnID: UUID().uuidString,
+ title: "Uncommitted changes",
status: .running,
startedAt: Date(timeIntervalSince1970: 200),
- summary: "Running review.",
- logEntries: [
+ chatEntries: [
.init(kind: .agentMessage, groupID: "msg_1", text: "Initial")
]
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
viewController.loadViewIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
- job.appendLogEntry(.init(kind: .progress, groupID: "progress_1", text: "stream.tick 001"))
- job.appendLogEntry(.init(kind: .progress, groupID: "progress_2", text: "stream.tick 002"))
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
+ await appendChatLogEntryForTesting(
+ .init(kind: .progress, groupID: "progress_1", text: "stream.tick 001"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ await appendChatLogEntryForTesting(
+ .init(kind: .progress, groupID: "progress_2", text: "stream.tick 002"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
- let snapshot = try await awaitTransportRender(transport)
+ let snapshot = try await awaitChatRenderForTesting(chat, in: transport)
#expect(snapshot.log.hasSuffix("stream.tick 002"))
}
@Test func coalescedMixedReasoningAndProgressSuffixAnimatesOnlyReasoningRange() async throws {
- let job = CodexReviewJob.makeForTesting(
- id: "job-coalesced-mixed-reasoning-progress",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-coalesced-mixed-reasoning-progress",
cwd: "/tmp/workspace-alpha",
- targetSummary: "Uncommitted changes",
- threadID: UUID().uuidString,
- turnID: UUID().uuidString,
+ title: "Uncommitted changes",
status: .running,
startedAt: Date(timeIntervalSince1970: 200),
- summary: "Running review.",
- logEntries: [
+ chatEntries: [
.init(kind: .rawReasoning, groupID: "reasoning_1", text: "Thinking")
]
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
viewController.loadViewIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
transport.setLogReduceMotionForTesting(false)
let wordGlowCount = transport.logWordGlowCountForTesting
- job.appendLogEntry(.init(kind: .rawReasoning, groupID: "reasoning_1", text: " ok"))
- job.appendLogEntry(.init(
- kind: .progress,
- groupID: "progress_1",
- text: String(repeating: "progress ", count: 20)
- ))
+ await appendChatLogEntryForTesting(
+ .init(kind: .rawReasoning, groupID: "reasoning_1", text: " ok"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ await appendChatLogEntryForTesting(
+ .init(
+ kind: .progress,
+ groupID: "progress_1",
+ text: String(repeating: "progress ", count: 20)
+ ),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
- let snapshot = try await awaitTransportRender(transport)
+ let snapshot = try await awaitChatRenderForTesting(chat, in: transport)
#expect(snapshot.log.contains("progress progress"))
#expect(transport.logAppendCountForTesting > 0)
#expect(transport.logWordGlowCountForTesting == wordGlowCount + 1)
}
@Test func shortLogAppendDoesNotGrowDocumentFrameBeforeContentIsScrollable() async throws {
- let job = makeJob(
- id: "job-short-append-frame-stability",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-short-append-frame-stability",
status: .running,
targetSummary: "Uncommitted changes",
summary: "Running review.",
logText: "review.start\n"
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
let harness = makeWindowHarness(store: store, contentSize: NSSize(width: 900, height: 600))
let viewController = harness.viewController
let window = harness.window
defer { window.close() }
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
transport.view.layoutSubtreeIfNeeded()
let initialDocumentFrame = transport.logDocumentViewFrameForTesting
#expect(transport.isLogPinnedToBottomForTesting)
- #expect(abs(transport.logMaximumVerticalScrollOffsetForTesting - transport.logMinimumVerticalScrollOffsetForTesting) < 0.5)
- job.appendLogEntry(.init(
- kind: .progress,
- text: "stream.tick 001 delta/layout +2 -0 while the short log remains below the scrollable viewport height"
- ))
- _ = try await awaitTransportRender(transport)
+ #expect(
+ abs(transport.logMaximumVerticalScrollOffsetForTesting - transport.logMinimumVerticalScrollOffsetForTesting)
+ < 0.5)
+ await appendChatLogEntryForTesting(
+ .init(
+ kind: .progress,
+ text:
+ "stream.tick 001 delta/layout +2 -0 while the short log remains below the scrollable viewport height"
+ ),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
transport.view.layoutSubtreeIfNeeded()
let appendedDocumentFrame = transport.logDocumentViewFrameForTesting
#expect(abs(appendedDocumentFrame.height - initialDocumentFrame.height) < 0.5)
- #expect(abs(transport.logMaximumVerticalScrollOffsetForTesting - transport.logMinimumVerticalScrollOffsetForTesting) < 0.5)
+ #expect(
+ abs(transport.logMaximumVerticalScrollOffsetForTesting - transport.logMinimumVerticalScrollOffsetForTesting)
+ < 0.5)
}
- @Test func selectedJobGroupedReplacementUsesReplacementPath() async throws {
- let job = CodexReviewJob.makeForTesting(
- id: "job-reload",
+ @Test func selectedReviewChatGroupedReplacementUsesReplacementPath() async throws {
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-reload",
cwd: "/tmp/workspace-alpha",
- targetSummary: "Uncommitted changes",
- threadID: UUID().uuidString,
- turnID: UUID().uuidString,
+ title: "Uncommitted changes",
status: .running,
startedAt: Date(timeIntervalSince1970: 200),
- summary: "Running review.",
- logEntries: [
+ chatEntries: [
.init(kind: .plan, groupID: "plan_1", text: "- original")
]
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
viewController.loadViewIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
let appendCount = transport.logAppendCountForTesting
let replaceCount = transport.logReplaceCountForTesting
let reloadCount = transport.logReloadCountForTesting
- job.appendLogEntry(.init(kind: .plan, groupID: "plan_1", replacesGroup: true, text: "- updated"))
+ await appendChatLogEntryForTesting(
+ .init(kind: .plan, groupID: "plan_1", replacesGroup: true, text: "- updated"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
- let snapshot = try await awaitTransportRender(transport)
+ let snapshot = try await awaitChatRenderForTesting(chat, in: transport)
#expect(snapshot.log == "- updated")
#expect(transport.logAppendCountForTesting == appendCount)
#expect(transport.logReplaceCountForTesting == replaceCount + 1)
#expect(transport.logReloadCountForTesting == reloadCount)
}
- @Test func commandCompletionAndReasoningAppendDoNotReloadFullLog() async throws {
+ @Test func coalescedCommandAppendAfterReasoningKeepsReasoningAndDoesNotReload() async throws {
let startedAt = Date(timeIntervalSince1970: 200)
- let completedAt = Date(timeIntervalSince1970: 203)
- let job = CodexReviewJob.makeForTesting(
- id: "job-command-close-reasoning-append",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-reasoning-command-append",
cwd: "/tmp/workspace-alpha",
- targetSummary: "Uncommitted changes",
- threadID: UUID().uuidString,
- turnID: UUID().uuidString,
+ title: "Uncommitted changes",
status: .running,
startedAt: startedAt,
- summary: "Running review.",
- logEntries: [
+ chatEntries: [
.init(
- kind: .command,
- groupID: "cmd_1",
- text: "$ git diff",
- metadata: .init(
- sourceType: "commandExecution",
- status: "inProgress",
- itemID: "cmd_1",
- command: "git diff",
- startedAt: startedAt,
- commandStatus: "inProgress"
- )
+ kind: .rawReasoning,
+ groupID: "reasoning_1",
+ text: "Need to inspect files."
)
]
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
let harness = makeWindowHarness(store: store, contentSize: NSSize(width: 860, height: 520))
let viewController = harness.viewController
let window = harness.window
defer { window.close() }
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
-
- job.appendLogEntry(.init(
- kind: .command,
- groupID: "cmd_1",
- replacesGroup: true,
- text: "$ git diff",
- metadata: .init(
- sourceType: "commandExecution",
- status: "completed",
- itemID: "cmd_1",
- command: "git diff",
- exitCode: 0,
- startedAt: startedAt,
- completedAt: completedAt,
- durationMs: 3_000,
- commandStatus: "completed"
- )
- ))
- job.appendLogEntry(.init(
- kind: .rawReasoning,
- groupID: "reasoning_1",
- text: "I found the relevant update path."
- ))
-
- let snapshot = try await awaitTransportRender(transport)
- #expect(snapshot.log.contains("Ran git diff for 3s"))
- #expect(snapshot.log.contains("I found the relevant update path."))
- }
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
+ let appendCount = transport.logAppendCountForTesting
+ let reloadCount = transport.logReloadCountForTesting
- @Test func coalescedCommandAppendAfterReasoningKeepsReasoningAndDoesNotReload() async throws {
- let startedAt = Date(timeIntervalSince1970: 200)
- let job = CodexReviewJob.makeForTesting(
- id: "job-reasoning-command-append",
- cwd: "/tmp/workspace-alpha",
- targetSummary: "Uncommitted changes",
- threadID: UUID().uuidString,
- turnID: UUID().uuidString,
- status: .running,
- startedAt: startedAt,
- summary: "Running review.",
- logEntries: [
- .init(
- kind: .rawReasoning,
- groupID: "reasoning_1",
- text: "Need to inspect files."
+ await appendChatLogEntryForTesting(
+ .init(
+ kind: .command,
+ groupID: "cmd_1",
+ text: "$ git diff",
+ metadata: .init(
+ sourceType: "commandExecution",
+ status: "inProgress",
+ itemID: "cmd_1",
+ command: "git diff",
+ startedAt: startedAt,
+ commandStatus: "inProgress"
)
- ]
+ ),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ await appendChatLogEntryForTesting(
+ .init(
+ kind: .rawReasoning,
+ groupID: "reasoning_2",
+ text: "Inspecting details after the command starts."
+ ),
+ to: chat.chatID,
+ turnID: chat.turnID
)
- let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let harness = makeWindowHarness(store: store, contentSize: NSSize(width: 860, height: 520))
- let viewController = harness.viewController
- let window = harness.window
- defer { window.close() }
- let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
- let appendCount = transport.logAppendCountForTesting
- let reloadCount = transport.logReloadCountForTesting
- job.appendLogEntry(.init(
- kind: .command,
- groupID: "cmd_1",
- text: "$ git diff",
- metadata: .init(
- sourceType: "commandExecution",
- status: "inProgress",
- itemID: "cmd_1",
- command: "git diff",
- startedAt: startedAt,
- commandStatus: "inProgress"
- )
- ))
- job.appendLogEntry(.init(
- kind: .rawReasoning,
- groupID: "reasoning_2",
- text: "Inspecting details after the command starts."
- ))
+ let snapshot = try await awaitChatRenderForTesting(
+ chatID: chat.chatID,
+ expectedLog: """
+ Need to inspect files.
+
+ Ran git diff
- let snapshot = try await awaitTransportRender(transport)
+ Inspecting details after the command starts.
+ """,
+ in: transport
+ )
#expect(snapshot.log.contains("Need to inspect files."))
- #expect(snapshot.log.contains("Running git diff"))
+ #expect(snapshot.log.contains("Ran git diff"))
#expect(snapshot.log.contains("Inspecting details after the command starts."))
#expect(transport.logAppendCountForTesting == appendCount + 1)
#expect(transport.logReloadCountForTesting == reloadCount)
}
- @Test func selectedJobMarkdownAppendReplacesTailBlockWithoutReload() async throws {
- let job = CodexReviewJob.makeForTesting(
- id: "job-markdown-append-fallback",
+ @Test func selectedReviewChatMarkdownAppendReplacesTailBlockWithoutReload() async throws {
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-markdown-append-fallback",
cwd: "/tmp/workspace-alpha",
- targetSummary: "Uncommitted changes",
- threadID: UUID().uuidString,
- turnID: UUID().uuidString,
+ title: "Uncommitted changes",
status: .running,
startedAt: Date(timeIntervalSince1970: 200),
- summary: "Running review.",
- logEntries: [
+ chatEntries: [
.init(kind: .agentMessage, groupID: "msg_1", text: "**bo")
]
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
viewController.loadViewIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
let appendCount = transport.logAppendCountForTesting
let replaceCount = transport.logReplaceCountForTesting
let reloadCount = transport.logReloadCountForTesting
- job.appendLogEntry(.init(kind: .agentMessage, groupID: "msg_1", text: "ld**"))
+ await appendChatLogEntryForTesting(
+ .init(kind: .agentMessage, groupID: "msg_1", text: "ld**"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
- let snapshot = try await awaitTransportRender(transport)
+ let snapshot = try await awaitChatRenderForTesting(chat, in: transport)
#expect(snapshot.log == "bold")
#expect(transport.logAppendCountForTesting == appendCount)
#expect(transport.logReplaceCountForTesting == replaceCount + 1)
#expect(transport.logReloadCountForTesting == reloadCount)
}
- @Test func skippedMarkdownRestyleReloadsBeforeSuffixAppendFallback() {
- let logScrollView = ReviewMonitorLogScrollView()
- let initialEntry = ReviewLogEntry(kind: .agentMessage, groupID: "msg_1", text: "bold")
- let restyledEntry = ReviewLogEntry(kind: .agentMessage, groupID: "msg_1", replacesGroup: true, text: "**bold**")
- let appendedEntry = ReviewLogEntry(kind: .agentMessage, groupID: "msg_1", text: " tail")
- var projection = ReviewMonitorLog.Projection()
- let initialDocument = projection.render(entries: [initialEntry])
- logScrollView.render(document: initialDocument, restoring: .top, allowIncrementalUpdate: false)
- let appendCount = logScrollView.appendCount
- let reloadCount = logScrollView.reloadCount
-
- _ = projection.render(entries: [initialEntry, restyledEntry])
- let latestDocument = projection.render(entries: [initialEntry, restyledEntry, appendedEntry])
- logScrollView.render(document: latestDocument, restoring: .top, allowIncrementalUpdate: true)
-
- #expect(logScrollView.displayedTextForTesting == "bold tail")
- #expect(logScrollView.appendCount == appendCount)
- #expect(logScrollView.reloadCount == reloadCount + 1)
- }
-
@Test func staleGroupedReplacementIsNotReplayedAfterHiddenCommandOutput() async throws {
- let job = CodexReviewJob.makeForTesting(
- id: "job-stale-replacement",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-stale-replacement",
cwd: "/tmp/workspace-alpha",
- targetSummary: "Uncommitted changes",
- threadID: UUID().uuidString,
- turnID: UUID().uuidString,
+ title: "Uncommitted changes",
status: .running,
startedAt: Date(timeIntervalSince1970: 200),
- summary: "Running review.",
- logEntries: [
+ chatEntries: [
.init(kind: .plan, groupID: "plan_1", text: "- original")
]
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
viewController.loadViewIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
- job.appendLogEntry(.init(
- kind: .plan,
- groupID: "plan_1",
- replacesGroup: true,
- text: "- updated with longer replacement text"
- ))
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
+ await appendChatLogEntryForTesting(
+ .init(
+ kind: .plan,
+ groupID: "plan_1",
+ replacesGroup: true,
+ text: "- updated with longer replacement text"
+ ),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
let replaceCount = transport.logReplaceCountForTesting
let appendCount = transport.logAppendCountForTesting
let reloadCount = transport.logReloadCountForTesting
- job.appendLogEntry(.init(kind: .commandOutput, groupID: "cmd_1", text: "hidden output"))
- _ = try await awaitTransportRender(transport) { snapshot in
+ await appendChatLogEntryForTesting(
+ .init(kind: .commandOutput, groupID: "cmd_1", text: "hidden output"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport) { snapshot in
snapshot.log.contains("Command output")
}
@@ -5266,148 +2794,197 @@ struct ReviewUITests {
}
@Test func metadataOnlyUpdatesDoNotTouchLogView() async throws {
- let job = makeJob(
- id: "job-metadata",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-metadata",
status: .running,
targetSummary: "Uncommitted changes",
summary: "Running review.",
logText: "Initial log\n"
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
+ let previewRuntime = try #require(previewRuntimeForTesting(on: store))
+ previewRuntime.start()
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store,
+ uiState: ReviewMonitorUIState(auth: store.auth),
+ codexModelSource: previewRuntime.modelSource
+ )
viewController.loadViewIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
let appendCount = transport.logAppendCountForTesting
let reloadCount = transport.logReloadCountForTesting
- job.updateStateForTesting(summary: "Updated summary.")
+ await previewRuntime.upsertPreviewItem(
+ id: "fixture-log-\(chat.id)",
+ kind: .agentMessage,
+ content: .message(.init(id: "fixture-log-\(chat.id)", role: .assistant, text: "Initial log")),
+ to: chat.chatID
+ )
- #expect(transport.displayedLogForTesting == "Initial log")
+ #expect(transport.displayedLogForTesting == reviewChatLogText(for: chat))
#expect(transport.logAppendCountForTesting == appendCount)
#expect(transport.logReloadCountForTesting == reloadCount)
}
@Test func reasoningAppendUsesWordGlowAndReduceMotionDisablesGlow() async throws {
- let job = CodexReviewJob.makeForTesting(
- id: "job-reasoning-glow",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-reasoning-glow",
cwd: "/tmp/workspace-alpha",
- targetSummary: "Uncommitted changes",
- threadID: UUID().uuidString,
- turnID: UUID().uuidString,
+ title: "Uncommitted changes",
status: .running,
startedAt: Date(timeIntervalSince1970: 200),
- summary: "Running review.",
- logEntries: [
+ chatEntries: [
.init(kind: .rawReasoning, groupID: "reasoning_1", text: "Thinking")
]
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
viewController.loadViewIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
transport.setLogReduceMotionForTesting(false)
- job.appendLogEntry(.init(kind: .rawReasoning, groupID: "reasoning_1", text: " through options"))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(kind: .rawReasoning, groupID: "reasoning_1", text: " through options"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
#expect(transport.logWordGlowCountForTesting == 2)
transport.completeLogWordGlowAnimationsForTesting()
#expect(transport.logWordGlowCountForTesting == 0)
- job.appendLogEntry(.init(kind: .rawReasoning, groupID: "reasoning_1", text: " again"))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(kind: .rawReasoning, groupID: "reasoning_1", text: " again"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
#expect(transport.logWordGlowCountForTesting == 1)
transport.setLogReduceMotionForTesting(true)
- job.appendLogEntry(.init(kind: .rawReasoning, groupID: "reasoning_1", text: " without animation"))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(kind: .rawReasoning, groupID: "reasoning_1", text: " without animation"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
#expect(transport.logWordGlowCountForTesting == 0)
}
@Test func screenSwitchBacklogDoesNotAnimateButNextVisibleReasoningAppendDoes() async throws {
- let firstJob = CodexReviewJob.makeForTesting(
- id: "job-reasoning-switch-backlog",
+ let firstChat = makeReviewChatFixtureForTesting(
+ id: "chat-reasoning-switch-backlog",
cwd: "/tmp/workspace-alpha",
- targetSummary: "Uncommitted changes",
- threadID: UUID().uuidString,
- turnID: UUID().uuidString,
+ title: "Uncommitted changes",
status: .running,
startedAt: Date(timeIntervalSince1970: 200),
- summary: "Running review.",
- logEntries: [
+ chatEntries: [
.init(kind: .rawReasoning, groupID: "reasoning_1", text: "Thinking")
]
)
- let secondJob = CodexReviewJob.makeForTesting(
- id: "job-other-selected",
+ let secondChat = makeReviewChatFixtureForTesting(
+ id: "chat-other-selected",
cwd: "/tmp/workspace-beta",
- targetSummary: "Uncommitted changes",
- threadID: UUID().uuidString,
- turnID: UUID().uuidString,
+ title: "Uncommitted changes",
status: .running,
startedAt: Date(timeIntervalSince1970: 201),
- summary: "Running review.",
- logEntries: [
- .init(kind: .agentMessage, groupID: "msg_1", text: "Other job")
+ chatEntries: [
+ .init(kind: .agentMessage, groupID: "msg_1", text: "Other chat")
]
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [firstJob, secondJob]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [firstChat, secondChat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
viewController.loadViewIfNeeded()
let transport = viewController.transportViewControllerForTesting
transport.setLogReduceMotionForTesting(false)
- viewController.sidebarViewControllerForTesting.selectJobForTesting(firstJob)
- _ = try await awaitTransportRender(transport)
- viewController.sidebarViewControllerForTesting.selectJobForTesting(secondJob)
- _ = try await awaitTransportRender(transport)
- firstJob.appendLogEntry(.init(kind: .rawReasoning, groupID: "reasoning_1", text: " hidden backlog"))
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: firstChat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ firstChat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: secondChat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ secondChat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
+ await appendChatLogEntryForTesting(
+ .init(kind: .rawReasoning, groupID: "reasoning_1", text: " hidden backlog"),
+ to: firstChat.chatID,
+ turnID: firstChat.turnID
+ )
- viewController.sidebarViewControllerForTesting.selectJobForTesting(firstJob)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: firstChat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ firstChat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
#expect(transport.logWordGlowCountForTesting == 0)
- firstJob.appendLogEntry(.init(kind: .rawReasoning, groupID: "reasoning_1", text: " live"))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(kind: .rawReasoning, groupID: "reasoning_1", text: " live"),
+ to: firstChat.chatID,
+ turnID: firstChat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(firstChat, in: transport)
#expect(transport.logWordGlowCountForTesting > 0)
}
@Test func reasoningWordGlowCompletesAndClearsRenderingAttributes() async throws {
- let job = CodexReviewJob.makeForTesting(
- id: "job-reasoning-glow-completion",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-reasoning-glow-completion",
cwd: "/tmp/workspace-alpha",
- targetSummary: "Uncommitted changes",
- threadID: UUID().uuidString,
- turnID: UUID().uuidString,
+ title: "Uncommitted changes",
status: .running,
startedAt: Date(timeIntervalSince1970: 200),
- summary: "Running review.",
- logEntries: [
+ chatEntries: [
.init(kind: .rawReasoning, groupID: "reasoning_1", text: "Thinking")
]
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
let harness = makeWindowHarness(store: store, contentSize: NSSize(width: 900, height: 360))
let viewController = harness.viewController
let window = harness.window
defer { window.close() }
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
transport.setLogReduceMotionForTesting(false)
let invalidationCount = transport.logWordFadeDisplayInvalidationCountForTesting
- job.appendLogEntry(.init(kind: .rawReasoning, groupID: "reasoning_1", text: " through options"))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(kind: .rawReasoning, groupID: "reasoning_1", text: " through options"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
#expect(transport.logWordGlowCountForTesting > 0)
#expect(transport.logWordFadeRenderingAttributeRangeCountForTesting > 0)
@@ -5422,32 +2999,37 @@ struct ReviewUITests {
}
@Test func delayedFirstWordGlowTickDoesNotImmediatelyClearAnimation() async throws {
- let job = CodexReviewJob.makeForTesting(
- id: "job-reasoning-glow-delayed-first-tick",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-reasoning-glow-delayed-first-tick",
cwd: "/tmp/workspace-alpha",
- targetSummary: "Uncommitted changes",
- threadID: UUID().uuidString,
- turnID: UUID().uuidString,
+ title: "Uncommitted changes",
status: .running,
startedAt: Date(timeIntervalSince1970: 200),
- summary: "Running review.",
- logEntries: [
+ chatEntries: [
.init(kind: .rawReasoning, groupID: "reasoning_1", text: "Thinking")
]
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
let harness = makeWindowHarness(store: store, contentSize: NSSize(width: 900, height: 360))
let viewController = harness.viewController
let window = harness.window
defer { window.close() }
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
transport.setLogReduceMotionForTesting(false)
- job.appendLogEntry(.init(kind: .rawReasoning, groupID: "reasoning_1", text: " ok"))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(kind: .rawReasoning, groupID: "reasoning_1", text: " ok"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
#expect(transport.logWordGlowCountForTesting > 0)
transport.advanceLogWordGlowAnimationsAfterInitialDelayForTesting(5)
@@ -5456,24 +3038,29 @@ struct ReviewUITests {
@Test func logAutoFollowRunsOnlyWhenPinnedToBottom() async throws {
let longLog = (0..<400).map { "line \($0)" }.joined(separator: "\n")
- let job = makeJob(
- id: "job-autofollow",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-autofollow",
status: .running,
targetSummary: "Uncommitted changes",
summary: "Running review.",
logText: longLog
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 900, height: 600))
viewController.loadViewIfNeeded()
viewController.view.layoutSubtreeIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
#expect(transport.isLogPinnedToBottomForTesting)
transport.scrollLogToBottomForTesting()
@@ -5482,38 +3069,50 @@ struct ReviewUITests {
transport.scrollLogToTopForTesting()
#expect(transport.isLogPinnedToBottomForTesting == false)
let unpinnedAutoFollow = transport.logAutoFollowCountForTesting
- job.appendLogEntry(.init(kind: .progress, text: "Unpinned update"))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(kind: .progress, text: "Unpinned update"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
#expect(transport.logAutoFollowCountForTesting == unpinnedAutoFollow)
#expect(transport.isLogPinnedToBottomForTesting == false)
transport.scrollLogToBottomForTesting()
#expect(transport.isLogPinnedToBottomForTesting)
let pinnedAutoFollow = transport.logAutoFollowCountForTesting
- job.appendLogEntry(.init(kind: .progress, text: "Pinned update"))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(kind: .progress, text: "Pinned update"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
#expect(transport.logAutoFollowCountForTesting == pinnedAutoFollow + 1)
#expect(transport.isLogPinnedToBottomForTesting)
}
@Test func logAutoFollowKeepsBottomAfterWrappedSingleLineAppend() async throws {
let longLog = (0..<400).map { "line \($0)" }.joined(separator: "\n")
- let job = makeJob(
- id: "job-autofollow-wrapped-append",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-autofollow-wrapped-append",
status: .running,
targetSummary: "Uncommitted changes",
summary: "Running review.",
logText: longLog
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
let harness = makeWindowHarness(store: store, contentSize: NSSize(width: 560, height: 360))
let viewController = harness.viewController
let window = harness.window
defer { window.close() }
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
transport.scrollLogToBottomForTesting()
#expect(transport.isLogPinnedToBottomForTesting)
@@ -5521,8 +3120,12 @@ struct ReviewUITests {
let wrappedLine = (0..<140)
.map { "wrapped-append-segment-\($0)" }
.joined(separator: " ")
- job.appendLogEntry(.init(kind: .progress, text: wrappedLine))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(kind: .progress, text: wrappedLine),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
#expect(transport.logAutoFollowCountForTesting == pinnedAutoFollow + 1)
#expect(transport.isLogPinnedToBottomForTesting)
@@ -5534,24 +3137,29 @@ struct ReviewUITests {
let longLog = (0..<500)
.map { "scroll stability line \($0) with enough text to keep TextKit 2 viewport layout active" }
.joined(separator: "\n")
- let job = makeJob(
- id: "job-append-near-bottom-scroll-stability",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-append-near-bottom-scroll-stability",
status: .running,
targetSummary: "Uncommitted changes",
summary: "Running review.",
logText: longLog
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 900, height: 600))
viewController.loadViewIfNeeded()
viewController.view.layoutSubtreeIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
let nearBottomOffset = transport.logMaximumVerticalScrollOffsetForTesting - 12
transport.scrollLogToOffsetForTesting(nearBottomOffset)
@@ -5559,11 +3167,15 @@ struct ReviewUITests {
let offsetBeforeAppend = transport.logVerticalScrollOffsetForTesting
let autoFollowBeforeAppend = transport.logAutoFollowCountForTesting
let programmaticScrollsBeforeAppend = transport.logProgrammaticScrollCountForTesting
- job.appendLogEntry(.init(
- kind: .progress,
- text: "Near-bottom append should not snap inertial or manual scrolling to the document end"
- ))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(
+ kind: .progress,
+ text: "Near-bottom append should not snap inertial or manual scrolling to the document end"
+ ),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
#expect(transport.logAutoFollowCountForTesting == autoFollowBeforeAppend)
#expect(transport.logProgrammaticScrollCountForTesting == programmaticScrollsBeforeAppend)
@@ -5573,31 +3185,40 @@ struct ReviewUITests {
@Test func programmaticLogAutoFollowRequestsOverlayScrollerHideWhenShown() async throws {
let longLog = (0..<400).map { "line \($0)" }.joined(separator: "\n")
- let job = makeJob(
- id: "job-overlay-hide",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-overlay-hide",
status: .running,
targetSummary: "Uncommitted changes",
summary: "Running review.",
logText: longLog
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 900, height: 600))
viewController.loadViewIfNeeded()
viewController.view.layoutSubtreeIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
transport.setLogScrollerStyleForTesting(.overlay)
transport.setLogOverlayScrollersShownForTesting(true)
transport.scrollLogToBottomForTesting()
let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting
- job.appendLogEntry(.init(kind: .progress, text: "Newest line"))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(kind: .progress, text: "Newest line"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
#expect(transport.isLogPinnedToBottomForTesting)
#expect(transport.logOverlayScrollerHideRequestCountForTesting == hideCountBeforeAppend + 1)
@@ -5605,186 +3226,244 @@ struct ReviewUITests {
@Test func legacyScrollerStyleDoesNotRequestOverlayScrollerHide() async throws {
let longLog = (0..<400).map { "line \($0)" }.joined(separator: "\n")
- let job = makeJob(
- id: "job-legacy-hide",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-legacy-hide",
status: .running,
targetSummary: "Uncommitted changes",
summary: "Running review.",
logText: longLog
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 900, height: 600))
viewController.loadViewIfNeeded()
viewController.view.layoutSubtreeIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
transport.setLogScrollerStyleForTesting(.legacy)
transport.setLogOverlayScrollersShownForTesting(true)
transport.scrollLogToBottomForTesting()
let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting
- job.appendLogEntry(.init(kind: .progress, text: "Newest line"))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(kind: .progress, text: "Newest line"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
#expect(transport.logOverlayScrollerHideRequestCountForTesting == hideCountBeforeAppend)
}
@Test func shortLogDoesNotRequestOverlayScrollerHideWhenNoScrollRange() async throws {
- let job = makeJob(
- id: "job-overlay-short",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-overlay-short",
status: .running,
targetSummary: "Uncommitted changes",
summary: "Running review.",
logText: "short log"
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 900, height: 600))
viewController.loadViewIfNeeded()
viewController.view.layoutSubtreeIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
transport.setLogScrollerStyleForTesting(.overlay)
transport.setLogOverlayScrollersShownForTesting(true)
let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting
- job.appendLogEntry(.init(kind: .progress, text: "short update"))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(kind: .progress, text: "short update"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
#expect(transport.logOverlayScrollerHideRequestCountForTesting == hideCountBeforeAppend)
}
- @Test func selectingJobRequestsOverlayScrollerHideWhenRestoringScrollPosition() async throws {
+ @Test func selectingReviewChatRequestsOverlayScrollerHideWhenRestoringScrollPosition() async throws {
let longLog = (0..<400).map { "line \($0)" }.joined(separator: "\n")
- let firstJob = makeJob(
- id: "job-restore-1",
+ let firstChat = makeReviewChatFixtureForTesting(
+ id: "chat-restore-1",
status: .running,
targetSummary: "Uncommitted changes",
summary: "Running review.",
logText: longLog
)
- let secondJob = makeJob(
- id: "job-restore-2",
+ let secondChat = makeReviewChatFixtureForTesting(
+ id: "chat-restore-2",
status: .running,
targetSummary: "Uncommitted changes",
summary: "Running review.",
- logText: longLog
+ logText: longLog + "\nsecond chat"
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [firstJob, secondJob]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [firstChat, secondChat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 900, height: 600))
viewController.loadViewIfNeeded()
viewController.view.layoutSubtreeIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(firstJob)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: firstChat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ firstChat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
transport.setLogScrollerStyleForTesting(.overlay)
transport.setLogOverlayScrollersShownForTesting(true)
transport.scrollLogToOffsetForTesting(120)
- viewController.sidebarViewControllerForTesting.selectJobForTesting(secondJob)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: secondChat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ secondChat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
let hideCountBeforeRestore = transport.logOverlayScrollerHideRequestCountForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(firstJob)
- _ = try await awaitTransportRender(transport)
+ transport.setLogOverlayScrollersShownForTesting(true)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: firstChat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ firstChat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
- #expect(transport.logOverlayScrollerHideRequestCountForTesting > hideCountBeforeRestore)
+ try await waitForOverlayScrollerHideRequest(
+ in: transport,
+ exceeding: hideCountBeforeRestore
+ )
}
@Test func privateOverlayBridgeNoOpsWhenScrollerImpPairIsUnavailable() async throws {
let longLog = (0..<400).map { "line \($0)" }.joined(separator: "\n")
- let job = makeJob(
- id: "job-missing-pair",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-missing-pair",
status: .running,
targetSummary: "Uncommitted changes",
summary: "Running review.",
logText: longLog
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 900, height: 600))
viewController.loadViewIfNeeded()
viewController.view.layoutSubtreeIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
transport.setLogScrollerStyleForTesting(.overlay)
transport.setLogOverlayScrollersShownForTesting(true)
transport.setLogOverlayScrollerBridgeModeForTesting(.missingScrollerImpPair)
let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting
- job.appendLogEntry(.init(kind: .progress, text: "Newest line"))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(kind: .progress, text: "Newest line"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
#expect(transport.logOverlayScrollerHideRequestCountForTesting == hideCountBeforeAppend)
}
@Test func privateOverlayBridgeNoOpsWhenHideSelectorsAreUnavailable() async throws {
let longLog = (0..<400).map { "line \($0)" }.joined(separator: "\n")
- let job = makeJob(
- id: "job-missing-hide",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-missing-hide",
status: .running,
targetSummary: "Uncommitted changes",
summary: "Running review.",
logText: longLog
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 900, height: 600))
viewController.loadViewIfNeeded()
viewController.view.layoutSubtreeIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
transport.setLogScrollerStyleForTesting(.overlay)
transport.setLogOverlayScrollersShownForTesting(true)
transport.setLogOverlayScrollerBridgeModeForTesting(.missingHideMethods)
let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting
- job.appendLogEntry(.init(kind: .progress, text: "Newest line"))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(kind: .progress, text: "Newest line"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
#expect(transport.logOverlayScrollerHideRequestCountForTesting == hideCountBeforeAppend)
}
@Test func logViewUsesCustomTextKit2SurfaceAndDisablesEditingFeatures() async throws {
- let job = makeJob(
- id: "job-log-config",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-log-config",
status: .running,
targetSummary: "Uncommitted changes",
summary: "Running review.",
logText: "Initial log\n"
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
viewController.loadViewIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
#expect(transport.logUsesCustomTextKit2SurfaceForTesting)
#expect(transport.logUsesTextViewForTesting == false)
- #expect(transport.logUsesLegacyLayoutManagerForTesting == false)
+ #expect(transport.logUsesLogLayoutManagerForTesting == false)
#expect(transport.logIsEditableForTesting == false)
#expect(transport.logIsSelectableForTesting)
#expect(transport.logHitTestTargetsDocumentViewForTesting)
@@ -5793,22 +3472,26 @@ struct ReviewUITests {
@Test func logViewMaintainsVisibleTextKit2FragmentCoverageWhenScrolled() async throws {
let longLog = (0..<1_000).map { "fragment coverage line \($0)" }.joined(separator: "\n")
- let job = makeJob(
- id: "job-log-fragments",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-log-fragments",
status: .running,
targetSummary: "Uncommitted changes",
summary: "Running review.",
logText: longLog
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
let harness = makeWindowHarness(store: store, contentSize: NSSize(width: 900, height: 520))
let viewController = harness.viewController
let window = harness.window
defer { window.close() }
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
transport.view.layoutSubtreeIfNeeded()
let bottomFragmentCount = transport.logVisibleFragmentViewCountForTesting
@@ -5832,25 +3515,33 @@ struct ReviewUITests {
@Test func logAppendDoesNotLeaveStaleTextKit2FragmentViews() async throws {
let longLog = (0..<400).map { "append fragment line \($0)" }.joined(separator: "\n")
- let job = makeJob(
- id: "job-log-fragment-append",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-log-fragment-append",
status: .running,
targetSummary: "Uncommitted changes",
summary: "Running review.",
logText: longLog
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
let harness = makeWindowHarness(store: store, contentSize: NSSize(width: 900, height: 520))
let viewController = harness.viewController
let window = harness.window
defer { window.close() }
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
let appendCount = transport.logAppendCountForTesting
- job.appendLogEntry(.init(kind: .progress, text: "Newest fragment line"))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(kind: .progress, text: "Newest fragment line"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
#expect(transport.logAppendCountForTesting == appendCount + 1)
#expect(transport.logVisibleFragmentViewCountForTesting > 0)
@@ -5858,25 +3549,30 @@ struct ReviewUITests {
}
@Test func logViewSupportsReadOnlySelectAllCopyFindValidationAndAccessibility() async throws {
- let job = makeJob(
- id: "job-log-readonly",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-log-readonly",
status: .running,
targetSummary: "Uncommitted changes",
summary: "Running review.",
logText: "First readonly line\nSecond readonly line\n"
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
viewController.loadViewIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
#expect(viewController.validateUserInterfaceItem(textFinderMenuItemForTesting(.showFindInterface)))
#expect(viewController.validateUserInterfaceItem(textFinderMenuItemForTesting(.nextMatch)))
#expect(viewController.validateUserInterfaceItem(textFinderMenuItemForTesting(.replace)) == false)
- #expect(transport.logAccessibilityValueForTesting == job.logText)
+ #expect(transport.logAccessibilityValueForTesting == reviewChatLogText(for: chat))
#expect(transport.logDocumentViewExportsUserInterfaceValidationForTesting)
let copyItem = commandMenuItemForTesting("copy:")
@@ -5891,7 +3587,7 @@ struct ReviewUITests {
#expect(transport.validateLogDocumentUserInterfaceItemForTesting(deleteItem) == false)
transport.selectAllLogForTesting()
- #expect(transport.logSelectedTextForTesting == job.logText)
+ #expect(transport.logSelectedTextForTesting == reviewChatLogText(for: chat))
#expect(transport.validateLogDocumentUserInterfaceItemForTesting(copyItem))
#expect(transport.validateLogDocumentUserInterfaceItemForTesting(cutItem) == false)
#expect(transport.validateLogDocumentUserInterfaceItemForTesting(pasteItem) == false)
@@ -5899,30 +3595,35 @@ struct ReviewUITests {
NSPasteboard.general.clearContents()
transport.copyLogSelectionForTesting()
- #expect(NSPasteboard.general.string(forType: .string) == job.logText)
+ #expect(NSPasteboard.general.string(forType: .string) == reviewChatLogText(for: chat))
transport.clearLogFinderSelectedRangesForTesting()
#expect(transport.logSelectedTextForTesting == nil)
#expect(transport.validateLogDocumentUserInterfaceItemForTesting(copyItem) == false)
transport.setSelectedLogRangeForTesting(NSRange(location: 0, length: 0))
- transport.performLogKeyboardCommandForTesting(#selector(NSStandardKeyBindingResponding.moveRightAndModifySelection(_:)))
- transport.performLogKeyboardCommandForTesting(#selector(NSStandardKeyBindingResponding.moveRightAndModifySelection(_:)))
+ transport.performLogKeyboardCommandForTesting(
+ #selector(NSStandardKeyBindingResponding.moveRightAndModifySelection(_:)))
+ transport.performLogKeyboardCommandForTesting(
+ #selector(NSStandardKeyBindingResponding.moveRightAndModifySelection(_:)))
#expect(transport.logSelectedTextForTesting == "Fi")
#expect(transport.validateLogDocumentUserInterfaceItemForTesting(copyItem))
transport.performLogKeyboardCommandForTesting(#selector(NSStandardKeyBindingResponding.moveRight(_:)))
#expect(transport.logSelectedTextForTesting == nil)
- transport.performLogKeyboardCommandForTesting(#selector(NSStandardKeyBindingResponding.moveRightAndModifySelection(_:)))
+ transport.performLogKeyboardCommandForTesting(
+ #selector(NSStandardKeyBindingResponding.moveRightAndModifySelection(_:)))
#expect(transport.logSelectedTextForTesting == "r")
let graphemeLog = "A🙂e\u{301}B\n"
transport.renderLogForTesting(text: graphemeLog, allowIncrementalUpdate: false)
transport.setSelectedLogRangeForTesting(NSRange(location: ("A" as NSString).length, length: 0))
- transport.performLogKeyboardCommandForTesting(#selector(NSStandardKeyBindingResponding.moveRightAndModifySelection(_:)))
+ transport.performLogKeyboardCommandForTesting(
+ #selector(NSStandardKeyBindingResponding.moveRightAndModifySelection(_:)))
#expect(transport.logSelectedTextForTesting == "🙂")
transport.setSelectedLogRangeForTesting(NSRange(location: ("A🙂" as NSString).length, length: 0))
- transport.performLogKeyboardCommandForTesting(#selector(NSStandardKeyBindingResponding.moveRightAndModifySelection(_:)))
+ transport.performLogKeyboardCommandForTesting(
+ #selector(NSStandardKeyBindingResponding.moveRightAndModifySelection(_:)))
#expect(transport.logSelectedTextForTesting == "e\u{301}")
}
@@ -5930,15 +3631,15 @@ struct ReviewUITests {
let wrappedLine = (1...80)
.map { "wrapped-segment-\($0)" }
.joined(separator: " ")
- let job = makeJob(
- id: "job-log-soft-wrap-keyboard",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-log-soft-wrap-keyboard",
status: .running,
targetSummary: "Uncommitted changes",
summary: "Running review.",
logText: wrappedLine + "\nnext logical line\n"
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
let harness = makeWindowHarness(store: store, contentSize: NSSize(width: 560, height: 360))
let viewController = harness.viewController
let window = harness.window
@@ -5946,21 +3647,27 @@ struct ReviewUITests {
viewController.loadViewIfNeeded()
viewController.view.layoutSubtreeIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
transport.scrollLogToTopForTesting()
#expect(transport.logVisibleFragmentViewCountForTesting > 0)
transport.setSelectedLogRangeForTesting(NSRange(location: 0, length: 0))
- transport.performLogKeyboardCommandForTesting(#selector(NSStandardKeyBindingResponding.moveToEndOfLineAndModifySelection(_:)))
+ transport.performLogKeyboardCommandForTesting(
+ #selector(NSStandardKeyBindingResponding.moveToEndOfLineAndModifySelection(_:)))
let selectedVisualLineEnd = try #require(transport.logSelectedTextForTesting)
#expect(selectedVisualLineEnd.isEmpty == false)
#expect(selectedVisualLineEnd.contains("\n") == false)
#expect((selectedVisualLineEnd as NSString).length < (wrappedLine as NSString).length)
transport.setSelectedLogRangeForTesting(NSRange(location: 0, length: 0))
- transport.performLogKeyboardCommandForTesting(#selector(NSStandardKeyBindingResponding.moveDownAndModifySelection(_:)))
+ transport.performLogKeyboardCommandForTesting(
+ #selector(NSStandardKeyBindingResponding.moveDownAndModifySelection(_:)))
let selectedVisualLineMove = try #require(transport.logSelectedTextForTesting)
#expect(selectedVisualLineMove.isEmpty == false)
#expect(selectedVisualLineMove.contains("\n") == false)
@@ -5968,29 +3675,35 @@ struct ReviewUITests {
}
@Test func logFindPreservesVisibleSearchStateDuringLogUpdatesUntilHidden() async throws {
- let initialLog = (1...140)
+ let initialLog =
+ (1...140)
.map { "needle \($0) with enough trailing text to wrap in the visible log surface" }
.joined(separator: "\n") + "\n"
- let job = makeJob(
- id: "job-log-find-system-highlights",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-log-find-system-highlights",
status: .running,
targetSummary: "Uncommitted changes",
summary: "Running review.",
logText: initialLog
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 900, height: 360))
viewController.loadViewIfNeeded()
viewController.view.layoutSubtreeIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
- let renderedInitialLog = reviewMonitorLogText(for: job)
+ let renderedInitialLog = reviewChatLogText(for: chat)
let renderedInitialLength = (renderedInitialLog as NSString).length
let visibleRanges = transport.logFindVisibleCharacterRangesForTesting
#expect(transport.logFindIncrementalSearchUsesSystemHighlightingForTesting)
@@ -6011,10 +3724,14 @@ struct ReviewUITests {
}
#expect(transport.logFindClientUsesSnapshotForTesting)
#expect(transport.logHasActiveFindQueryForTesting)
- job.appendLogEntry(.init(kind: .progress, text: "needle appended"))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(kind: .progress, text: "needle appended"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
- let appendedLength = (reviewMonitorLogText(for: job) as NSString).length
+ let appendedLength = (reviewChatLogText(for: chat) as NSString).length
let appendedVisibleRanges = transport.logFindVisibleCharacterRangesForTesting
#expect(transport.logFindStringLengthForTesting == renderedInitialLength)
#expect(transport.logSelectedTextForTesting == "needle")
@@ -6031,12 +3748,18 @@ struct ReviewUITests {
let middleOffset = transport.logMaximumVerticalScrollOffsetForTesting / 2
let findIndicatorInvalidationCountBeforeSnapshotScroll = transport.logFindIndicatorInvalidationCountForTesting
transport.scrollLogToOffsetForTesting(middleOffset)
- #expect(transport.logFindIndicatorInvalidationCountForTesting == findIndicatorInvalidationCountBeforeSnapshotScroll + 1)
+ #expect(
+ transport.logFindIndicatorInvalidationCountForTesting == findIndicatorInvalidationCountBeforeSnapshotScroll
+ + 1)
#expect(transport.isLogPinnedToBottomForTesting == false)
let offsetBeforeMiddleAppend = transport.logVerticalScrollOffsetForTesting
- job.appendLogEntry(.init(kind: .progress, text: "needle appended while the log is not following bottom"))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(kind: .progress, text: "needle appended while the log is not following bottom"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
#expect(abs(transport.logVerticalScrollOffsetForTesting - offsetBeforeMiddleAppend) < 0.5)
#expect(transport.logSelectedTextForTesting == "needle")
@@ -6044,7 +3767,7 @@ struct ReviewUITests {
#expect(transport.logFindClientUsesSnapshotForTesting)
#expect(transport.logFindClientSnapshotMapsToDocumentForTesting)
- var burstText = reviewMonitorLogText(for: job)
+ var burstText = reviewChatLogText(for: chat)
for index in 0..<8 {
burstText += "\nneedle burst \(index)"
#expect(transport.renderLogForTesting(text: burstText, allowIncrementalUpdate: true))
@@ -6057,10 +3780,11 @@ struct ReviewUITests {
let reloadedText = "replacement header\nneedle after structural reload\n"
let reloadedLength = (reloadedText as NSString).length
- #expect(transport.renderLogForTesting(
- text: reloadedText,
- allowIncrementalUpdate: false
- ))
+ #expect(
+ transport.renderLogForTesting(
+ text: reloadedText,
+ allowIncrementalUpdate: false
+ ))
#expect(transport.logSelectedTextForTesting == nil)
#expect(transport.logFindBarVisibleForTesting)
@@ -6072,18 +3796,20 @@ struct ReviewUITests {
#expect(transport.logSelectedTextForTesting == nil)
#expect(NSMaxRange(transport.logSelectedRangeForTesting) <= reloadedLength)
- #expect(transport.renderLogForTesting(
- text: "",
- allowIncrementalUpdate: false
- ))
+ #expect(
+ transport.renderLogForTesting(
+ text: "",
+ allowIncrementalUpdate: false
+ ))
#expect(transport.logFindClientUsesSnapshotForTesting == false)
#expect(transport.logFindStringLengthForTesting == 0)
let liveReloadText = "needle after empty structural reload\n"
- #expect(transport.renderLogForTesting(
- text: liveReloadText,
- allowIncrementalUpdate: false
- ))
+ #expect(
+ transport.renderLogForTesting(
+ text: liveReloadText,
+ allowIncrementalUpdate: false
+ ))
#expect(transport.logFindClientUsesSnapshotForTesting == false)
#expect(transport.logFindStringLengthForTesting == (liveReloadText as NSString).length)
@@ -6093,140 +3819,178 @@ struct ReviewUITests {
#expect(transport.logFindIncrementalSearchUsesSystemHighlightingForTesting)
let hiddenUpdateText = liveReloadText + "\nneedle after close\n"
- #expect(transport.renderLogForTesting(
- text: hiddenUpdateText,
- allowIncrementalUpdate: true
- ))
+ #expect(
+ transport.renderLogForTesting(
+ text: hiddenUpdateText,
+ allowIncrementalUpdate: true
+ ))
#expect(transport.logFindStringLengthForTesting == (hiddenUpdateText as NSString).length)
}
@Test func logFindClearsVisibleSnapshotWhenLogContentIsReused() async throws {
- let firstJob = makeJob(
- id: "job-log-find-reuse-first",
+ let firstChat = makeReviewChatFixtureForTesting(
+ id: "chat-log-find-reuse-first",
status: .running,
- targetSummary: "First job",
+ targetSummary: "First chat",
summary: "Running review.",
logText: "needle first job\n"
)
- let secondJob = makeJob(
- id: "job-log-find-reuse-second",
+ let secondChat = makeReviewChatFixtureForTesting(
+ id: "chat-log-find-reuse-second",
status: .running,
- targetSummary: "Second job",
+ targetSummary: "Second chat",
summary: "Running review.",
logText: "needle second job\n"
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [firstJob, secondJob]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [firstChat, secondChat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 720, height: 320))
viewController.loadViewIfNeeded()
viewController.view.layoutSubtreeIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(firstJob)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: firstChat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ firstChat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
- let firstNeedleRange = (reviewMonitorLogText(for: firstJob) as NSString).range(of: "needle")
+ let firstNeedleRange = (reviewChatLogText(for: firstChat) as NSString).range(of: "needle")
#expect(firstNeedleRange.location != NSNotFound)
transport.setSelectedLogRangeForTesting(firstNeedleRange)
viewController.performTextFinderAction(textFinderMenuItemForTesting(.setSearchString))
viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface))
- firstJob.appendLogEntry(.init(kind: .progress, text: "needle appended"))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(kind: .progress, text: "needle appended"),
+ to: firstChat.chatID,
+ turnID: firstChat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(firstChat, in: transport)
#expect(transport.logFindBarVisibleForTesting)
#expect(transport.logFindClientUsesSnapshotForTesting)
viewController.sidebarViewControllerForTesting.clearSelectionForTesting()
- _ = try await awaitTransportRender(transport)
+ _ = try await awaitTransportRender(
+ transport,
+ expectedSelection: nil
+ )
#expect(transport.logFindBarVisibleForTesting)
#expect(transport.logFindClientUsesSnapshotForTesting == false)
- viewController.sidebarViewControllerForTesting.selectJobForTesting(secondJob)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: secondChat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ secondChat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
#expect(transport.logFindBarVisibleForTesting)
#expect(transport.logFindClientUsesSnapshotForTesting == false)
- #expect(transport.logFindStringLengthForTesting == (reviewMonitorLogText(for: secondJob) as NSString).length)
+ #expect(transport.logFindStringLengthForTesting == (transport.displayedLogForTesting as NSString).length)
}
@Test func logFindContentReuseClearsSnapshotWhenSameTextSkipsRender() async throws {
let initialLog = "needle initial"
let appendedLine = "needle appended"
let reusedLog = initialLog + "\n\n" + appendedLine
- let firstJob = makeJob(
- id: "job-log-find-same-text-reuse-first",
+ let firstChat = makeReviewChatFixtureForTesting(
+ id: "chat-log-find-same-text-reuse-first",
status: .running,
- targetSummary: "First job",
+ targetSummary: "First chat",
summary: "Running review.",
logText: initialLog
)
- let secondJob = makeJob(
- id: "job-log-find-same-text-reuse-second",
+ let secondChat = makeReviewChatFixtureForTesting(
+ id: "chat-log-find-same-text-reuse-second",
status: .running,
- targetSummary: "Second job",
+ targetSummary: "Second chat",
summary: "Running review.",
logText: reusedLog
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [firstJob, secondJob]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [firstChat, secondChat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 720, height: 320))
viewController.loadViewIfNeeded()
viewController.view.layoutSubtreeIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(firstJob)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: firstChat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ firstChat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
- let firstNeedleRange = (reviewMonitorLogText(for: firstJob) as NSString).range(of: "needle")
+ let firstNeedleRange = (reviewChatLogText(for: firstChat) as NSString).range(of: "needle")
#expect(firstNeedleRange.location != NSNotFound)
transport.setSelectedLogRangeForTesting(firstNeedleRange)
viewController.performTextFinderAction(textFinderMenuItemForTesting(.setSearchString))
viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface))
- firstJob.appendLogEntry(.init(kind: .progress, text: appendedLine))
- _ = try await awaitTransportRender(transport)
- #expect(transport.displayedLogForTesting == reviewMonitorLogText(for: secondJob))
+ await appendChatLogEntryForTesting(
+ .init(kind: .progress, text: appendedLine),
+ to: firstChat.chatID,
+ turnID: firstChat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(firstChat, in: transport)
+ #expect(
+ transport.displayedLogForTesting.trimmingCharacters(in: .newlines)
+ == reviewChatLogText(for: secondChat).trimmingCharacters(in: .newlines)
+ )
#expect(transport.logFindBarVisibleForTesting)
#expect(transport.logFindClientUsesSnapshotForTesting)
- viewController.sidebarViewControllerForTesting.selectJobForTesting(secondJob)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: secondChat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ secondChat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
#expect(transport.logFindBarVisibleForTesting)
#expect(transport.logFindClientUsesSnapshotForTesting == false)
- #expect(transport.logFindStringLengthForTesting == (reviewMonitorLogText(for: secondJob) as NSString).length)
+ #expect(transport.logFindStringLengthForTesting == (transport.displayedLogForTesting as NSString).length)
}
@Test func logFindContentReuseClearsSnapshotForPrefixRelatedLogs() async throws {
let firstLog = "needle shared prefix\n"
- let firstJob = makeJob(
- id: "job-log-find-prefix-reuse-first",
+ let firstChat = makeReviewChatFixtureForTesting(
+ id: "chat-log-find-prefix-reuse-first",
status: .running,
- targetSummary: "First job",
+ targetSummary: "First chat",
summary: "Running review.",
logText: firstLog
)
- let secondJob = makeJob(
- id: "job-log-find-prefix-reuse-second",
+ let secondChat = makeReviewChatFixtureForTesting(
+ id: "chat-log-find-prefix-reuse-second",
status: .running,
- targetSummary: "Second job",
+ targetSummary: "Second chat",
summary: "Running review.",
logText: firstLog + "needle second suffix\n"
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [firstJob, secondJob]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [firstChat, secondChat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 720, height: 320))
viewController.loadViewIfNeeded()
viewController.view.layoutSubtreeIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(firstJob)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: firstChat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ firstChat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
- let firstNeedleRange = (reviewMonitorLogText(for: firstJob) as NSString).range(of: "needle")
+ let firstNeedleRange = (reviewChatLogText(for: firstChat) as NSString).range(of: "needle")
#expect(firstNeedleRange.location != NSNotFound)
transport.setSelectedLogRangeForTesting(firstNeedleRange)
viewController.performTextFinderAction(textFinderMenuItemForTesting(.setSearchString))
@@ -6236,78 +4000,100 @@ struct ReviewUITests {
#expect(transport.logFindClientUsesSnapshotForTesting == false)
let finderIdentifierBeforeSwitch = transport.logTextFinderIdentifierForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(secondJob)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: secondChat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ secondChat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
#expect(transport.logFindBarVisibleForTesting)
#expect(transport.logTextFinderIdentifierForTesting == finderIdentifierBeforeSwitch)
#expect(transport.logFindClientUsesSnapshotForTesting == false)
- #expect(transport.logFindStringLengthForTesting == (reviewMonitorLogText(for: secondJob) as NSString).length)
+ #expect(transport.logFindStringLengthForTesting == (reviewChatLogText(for: secondChat) as NSString).length)
}
@Test func logFindHidingVisibleSnapshotReturnsClientToLiveString() async throws {
- let job = makeJob(
- id: "job-log-find-hide-snapshot",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-log-find-hide-snapshot",
status: .running,
targetSummary: "Hide snapshot",
summary: "Running review.",
logText: "needle initial\n"
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 720, height: 320))
viewController.loadViewIfNeeded()
viewController.view.layoutSubtreeIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
- let firstNeedleRange = (reviewMonitorLogText(for: job) as NSString).range(of: "needle")
+ let firstNeedleRange = (reviewChatLogText(for: chat) as NSString).range(of: "needle")
#expect(firstNeedleRange.location != NSNotFound)
transport.setSelectedLogRangeForTesting(firstNeedleRange)
viewController.performTextFinderAction(textFinderMenuItemForTesting(.setSearchString))
viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface))
- job.appendLogEntry(.init(kind: .progress, text: "needle appended"))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(kind: .progress, text: "needle appended"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
#expect(transport.logFindClientUsesSnapshotForTesting)
viewController.performTextFinderAction(textFinderMenuItemForTesting(.hideFindInterface))
#expect(transport.logFindBarVisibleForTesting == false)
#expect(transport.logFindClientUsesSnapshotForTesting == false)
- #expect(transport.logFindStringLengthForTesting == (reviewMonitorLogText(for: job) as NSString).length)
+ #expect(transport.logFindStringLengthForTesting == (reviewChatLogText(for: chat) as NSString).length)
}
@Test func logFindClearedSelectionReturnsVisibleUpdatesToLiveString() async throws {
- let job = makeJob(
- id: "job-log-find-cleared-selection",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-log-find-cleared-selection",
status: .running,
targetSummary: "Cleared selection",
summary: "Running review.",
logText: "needle initial\n"
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 720, height: 320))
viewController.loadViewIfNeeded()
viewController.view.layoutSubtreeIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
- let firstNeedleRange = (reviewMonitorLogText(for: job) as NSString).range(of: "needle")
+ let firstNeedleRange = (reviewChatLogText(for: chat) as NSString).range(of: "needle")
#expect(firstNeedleRange.location != NSNotFound)
transport.setSelectedLogRangeForTesting(firstNeedleRange)
viewController.performTextFinderAction(textFinderMenuItemForTesting(.setSearchString))
viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface))
- job.appendLogEntry(.init(kind: .progress, text: "needle appended into snapshot"))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(kind: .progress, text: "needle appended into snapshot"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
#expect(transport.logFindBarVisibleForTesting)
#expect(transport.logFindClientUsesSnapshotForTesting)
@@ -6316,41 +4102,54 @@ struct ReviewUITests {
#expect(transport.logFindClientFirstSelectedRangeForTesting.length == 0)
#expect(transport.logSelectedTextForTesting == nil)
#expect(transport.logFindClientUsesSnapshotForTesting == false)
- job.appendLogEntry(.init(kind: .progress, text: "needle appended after cleared selection"))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(kind: .progress, text: "needle appended after cleared selection"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
#expect(transport.logFindBarVisibleForTesting)
#expect(transport.logFindClientUsesSnapshotForTesting == false)
- #expect(transport.logFindStringLengthForTesting == (reviewMonitorLogText(for: job) as NSString).length)
+ #expect(transport.logFindStringLengthForTesting == (reviewChatLogText(for: chat) as NSString).length)
}
@Test func logFindClearedQueryReturnsVisibleUpdatesToLiveString() async throws {
- let job = makeJob(
- id: "job-log-find-cleared-query",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-log-find-cleared-query",
status: .running,
targetSummary: "Cleared query",
summary: "Running review.",
logText: "needle initial\n"
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 720, height: 320))
viewController.loadViewIfNeeded()
viewController.view.layoutSubtreeIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
- let firstNeedleRange = (reviewMonitorLogText(for: job) as NSString).range(of: "needle")
+ let firstNeedleRange = (reviewChatLogText(for: chat) as NSString).range(of: "needle")
#expect(firstNeedleRange.location != NSNotFound)
transport.setSelectedLogRangeForTesting(firstNeedleRange)
viewController.performTextFinderAction(textFinderMenuItemForTesting(.setSearchString))
viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface))
- job.appendLogEntry(.init(kind: .progress, text: "needle appended into snapshot"))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(kind: .progress, text: "needle appended into snapshot"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
#expect(transport.logFindBarVisibleForTesting)
#expect(transport.logFindClientUsesSnapshotForTesting)
@@ -6360,34 +4159,43 @@ struct ReviewUITests {
#expect(transport.logFindClientFirstSelectedRangeForTesting.length == 0)
#expect(transport.logHasActiveFindQueryForTesting == false)
#expect(transport.logFindClientUsesSnapshotForTesting == false)
- job.appendLogEntry(.init(kind: .progress, text: "needle appended after cleared query"))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(kind: .progress, text: "needle appended after cleared query"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
#expect(transport.logFindBarVisibleForTesting)
#expect(transport.logFindClientUsesSnapshotForTesting == false)
- #expect(transport.logFindStringLengthForTesting == (reviewMonitorLogText(for: job) as NSString).length)
+ #expect(transport.logFindStringLengthForTesting == (reviewChatLogText(for: chat) as NSString).length)
}
}
@Test func logFindDoesNotFreezeVisibleBarBeforeSearchQuery() async throws {
- let job = makeJob(
- id: "job-log-find-visible-no-query",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-log-find-visible-no-query",
status: .running,
targetSummary: "Visible find bar",
summary: "Running review.",
logText: "initial log\n"
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 720, height: 320))
viewController.loadViewIfNeeded()
viewController.view.layoutSubtreeIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
try await withFindPasteboardString(nil) {
viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface))
@@ -6395,44 +4203,57 @@ struct ReviewUITests {
#expect(transport.setLogVisibleFindBarSearchStringForTesting(""))
#expect(transport.logVisibleFindBarSearchStringForTesting == "")
#expect(transport.logFindClientUsesSnapshotForTesting == false)
- job.appendLogEntry(.init(kind: .progress, text: "future-only needle"))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(kind: .progress, text: "future-only needle"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
#expect(transport.logFindBarVisibleForTesting)
#expect(transport.logFindClientUsesSnapshotForTesting == false)
- #expect(transport.logFindStringLengthForTesting == (reviewMonitorLogText(for: job) as NSString).length)
+ #expect(transport.logFindStringLengthForTesting == (reviewChatLogText(for: chat) as NSString).length)
}
}
@Test func logFindPreservesDirectFindBarQueryDuringLogUpdates() async throws {
- let job = makeJob(
- id: "job-log-find-direct-query",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-log-find-direct-query",
status: .running,
targetSummary: "Visible find bar direct query",
summary: "Running review.",
logText: "core initial log\n"
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 720, height: 320))
viewController.loadViewIfNeeded()
viewController.view.layoutSubtreeIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
- let initialLength = (reviewMonitorLogText(for: job) as NSString).length
+ let initialLength = (reviewChatLogText(for: chat) as NSString).length
try await withFindPasteboardString(nil) {
viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface))
#expect(transport.logFindBarVisibleForTesting)
#expect(transport.setLogVisibleFindBarSearchStringForTesting("core"))
#expect(transport.logVisibleFindBarSearchStringForTesting == "core")
#expect(transport.logHasActiveFindQueryForTesting)
- job.appendLogEntry(.init(kind: .progress, text: "core appended while query is visible"))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(kind: .progress, text: "core appended while query is visible"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
#expect(transport.logFindBarVisibleForTesting)
#expect(transport.logVisibleFindBarSearchStringForTesting == "core")
@@ -6443,34 +4264,43 @@ struct ReviewUITests {
}
@Test func logFindQueryChangeRefreshesVisibleSnapshotAfterLogUpdates() async throws {
- let job = makeJob(
- id: "job-log-find-query-change",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-log-find-query-change",
status: .running,
targetSummary: "Visible find bar query change",
summary: "Running review.",
logText: "alpha initial log\n"
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 720, height: 320))
viewController.loadViewIfNeeded()
viewController.view.layoutSubtreeIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
- let initialLength = (reviewMonitorLogText(for: job) as NSString).length
+ let initialLength = (reviewChatLogText(for: chat) as NSString).length
try await withFindPasteboardString(nil) {
viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface))
#expect(transport.logFindBarVisibleForTesting)
#expect(transport.setLogVisibleFindBarSearchStringForTesting("alpha"))
#expect(transport.logFindStringLengthForTesting == initialLength)
- job.appendLogEntry(.init(kind: .progress, text: "beta appended after active search"))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(kind: .progress, text: "beta appended after active search"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
#expect(transport.logFindClientUsesSnapshotForTesting)
#expect(transport.logFindStringLengthForTesting == initialLength)
@@ -6478,68 +4308,82 @@ struct ReviewUITests {
#expect(transport.logVisibleFindBarSearchStringForTesting == "beta")
#expect(transport.logFindClientUsesSnapshotForTesting)
#expect(transport.logFindClientSnapshotMapsToDocumentForTesting)
- #expect(transport.logFindStringLengthForTesting == (reviewMonitorLogText(for: job) as NSString).length)
+ #expect(transport.logFindStringLengthForTesting == (reviewChatLogText(for: chat) as NSString).length)
}
}
@Test func logFindVisibleBarNormalSelectionKeepsUpdatesLive() async throws {
- let job = makeJob(
- id: "job-log-find-visible-normal-selection",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-log-find-visible-normal-selection",
status: .running,
targetSummary: "Visible find bar normal selection",
summary: "Running review.",
logText: "copyable text before updates\n"
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 720, height: 320))
viewController.loadViewIfNeeded()
viewController.view.layoutSubtreeIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
try await withFindPasteboardString(nil) {
viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface))
#expect(transport.logFindBarVisibleForTesting)
#expect(transport.setLogVisibleFindBarSearchStringForTesting(""))
#expect(transport.logVisibleFindBarSearchStringForTesting == "")
- let normalSelectionRange = (reviewMonitorLogText(for: job) as NSString).range(of: "copyable")
+ let normalSelectionRange = (reviewChatLogText(for: chat) as NSString).range(of: "copyable")
#expect(normalSelectionRange.location != NSNotFound)
transport.setSelectedLogRangeForTesting(normalSelectionRange)
#expect(transport.logSelectedTextForTesting == "copyable")
#expect(transport.logHasActiveFindQueryForTesting == false)
- job.appendLogEntry(.init(kind: .progress, text: "needle appended after normal selection"))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(kind: .progress, text: "needle appended after normal selection"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
#expect(transport.logFindBarVisibleForTesting)
#expect(transport.logFindClientUsesSnapshotForTesting == false)
- #expect(transport.logFindStringLengthForTesting == (reviewMonitorLogText(for: job) as NSString).length)
+ #expect(transport.logFindStringLengthForTesting == (reviewChatLogText(for: chat) as NSString).length)
}
}
@Test func logFindPreservesNoResultSearchStateDuringLogUpdatesUntilHidden() async throws {
- let job = makeJob(
- id: "job-log-find-visible-no-result",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-log-find-visible-no-result",
status: .running,
targetSummary: "Visible find bar no result",
summary: "Running review.",
logText: "initial log without the active query\n"
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 720, height: 320))
viewController.loadViewIfNeeded()
viewController.view.layoutSubtreeIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface))
#expect(transport.logFindBarVisibleForTesting)
@@ -6549,9 +4393,13 @@ struct ReviewUITests {
transport.simulateLogFinderEmptySelectedRangesForTesting()
#expect(transport.logHasActiveFindQueryForTesting)
- let initialLength = (reviewMonitorLogText(for: job) as NSString).length
- job.appendLogEntry(.init(kind: .progress, text: "active query appears after no-result search"))
- _ = try await awaitTransportRender(transport)
+ let initialLength = (reviewChatLogText(for: chat) as NSString).length
+ await appendChatLogEntryForTesting(
+ .init(kind: .progress, text: "active query appears after no-result search"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
#expect(transport.logFindBarVisibleForTesting)
#expect(transport.logFindClientUsesSnapshotForTesting)
@@ -6583,40 +4431,49 @@ struct ReviewUITests {
}
@Test func logFindKeepsFirstAppendIntoEmptyVisibleLogLive() async throws {
- let job = makeJob(
- id: "job-log-find-empty-append",
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-log-find-empty-append",
status: .running,
targetSummary: "Empty log",
summary: "Running review.",
logText: ""
)
let store = CodexReviewStore.makePreviewStore()
- store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job]))
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ store.loadForTesting(serverState: .running, fixtures: [chat])
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
let window = NSWindow(contentViewController: viewController)
defer { window.close() }
window.setContentSize(NSSize(width: 720, height: 320))
viewController.loadViewIfNeeded()
viewController.view.layoutSubtreeIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
- _ = try await awaitTransportRender(transport)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
+ _ = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface))
#expect(transport.logFindBarVisibleForTesting)
#expect(transport.setLogVisibleFindBarSearchStringForTesting(""))
#expect(transport.logFindStringLengthForTesting == 0)
- job.appendLogEntry(.init(kind: .progress, text: "needle first content"))
- _ = try await awaitTransportRender(transport)
+ await appendChatLogEntryForTesting(
+ .init(kind: .progress, text: "needle first content"),
+ to: chat.chatID,
+ turnID: chat.turnID
+ )
+ _ = try await awaitChatRenderForTesting(chat, in: transport)
#expect(transport.logFindBarVisibleForTesting)
#expect(transport.logFindClientUsesSnapshotForTesting == false)
- #expect(transport.logFindStringLengthForTesting == (reviewMonitorLogText(for: job) as NSString).length)
+ #expect(transport.logFindStringLengthForTesting == (reviewChatLogText(for: chat) as NSString).length)
}
- @Test func authFailedJobShowsNormalFailureDetails() async throws {
- let job = makeJob(
- id: "job-auth",
+ @Test func authFailedChatShowsNormalFailureDetails() async throws {
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-auth",
status: .failed,
targetSummary: "Uncommitted changes",
summary: "Failed to start review.",
@@ -6626,21 +4483,25 @@ struct ReviewUITests {
store.loadForTesting(
serverState: .running,
authState: .signedOut,
- content: makeSidebarContent(from: [job])
+ fixtures: [chat]
)
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
viewController.loadViewIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
- let snapshot = try await awaitTransportRender(transport)
- #expect(snapshot.summary == nil)
- #expect(snapshot.log == "Authentication required. Sign in to ReviewMonitor and retry.")
+ let snapshot = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
+ #expect(snapshot.log == reviewChatLogText(for: chat))
}
- @Test func authenticatedAuthFailedJobStillShowsNormalFailureDetails() async throws {
- let job = makeJob(
- id: "job-auth-restored",
+ @Test func authenticatedAuthFailedChatStillShowsNormalFailureDetails() async throws {
+ let chat = makeReviewChatFixtureForTesting(
+ id: "chat-auth-restored",
status: .failed,
targetSummary: "Uncommitted changes",
summary: "Failed to start review.",
@@ -6650,16 +4511,20 @@ struct ReviewUITests {
store.loadForTesting(
serverState: .running,
authState: .signedIn(accountID: "review@example.com"),
- content: makeSidebarContent(from: [job])
+ fixtures: [chat]
)
- let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth))
+ let viewController = makeReviewMonitorSplitViewControllerForTesting(
+ store: store, uiState: ReviewMonitorUIState(auth: store.auth))
viewController.loadViewIfNeeded()
let transport = viewController.transportViewControllerForTesting
- viewController.sidebarViewControllerForTesting.selectJobForTesting(job)
+ viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID)
- let snapshot = try await awaitTransportRender(transport)
- #expect(snapshot.summary == nil)
- #expect(snapshot.log == "Authentication required. Sign in to ReviewMonitor and retry.")
+ let snapshot = try await awaitChatRenderForTesting(
+ chat,
+ in: transport,
+ allowIncrementalUpdate: false
+ )
+ #expect(snapshot.log == reviewChatLogText(for: chat))
}
}
@@ -6691,14 +4556,18 @@ func makeWindowHarness(
store: CodexReviewStore,
authState: TestAuthState = .signedIn(accountID: "review@example.com"),
contentSize: NSSize? = nil,
- sidebarJobFilterDefaults: UserDefaults? = nil,
- contentTransitionAnimator: @escaping ReviewMonitorContentTransitionAnimator = ReviewMonitorRootViewController.defaultContentTransitionAnimator
+ sidebarReviewChatFilterDefaults: UserDefaults? = nil,
+ contentTransitionAnimator: @escaping ReviewMonitorContentTransitionAnimator = ReviewMonitorRootViewController
+ .defaultContentTransitionAnimator
) -> ReviewMonitorWindowHarness {
applyTestAuthState(auth: store.auth, state: authState)
+ let previewRuntime = previewRuntimeForTesting(on: store)
+ previewRuntime?.start()
let windowController = ReviewMonitorWindowController(
store: store,
+ codexModelSource: previewRuntime?.modelSource,
contentTransitionAnimator: contentTransitionAnimator,
- sidebarJobFilterDefaults: sidebarJobFilterDefaults
+ sidebarReviewChatFilterDefaults: sidebarReviewChatFilterDefaults
)
guard let window = windowController.window else {
fatalError("ReviewMonitorWindowController did not create a window.")
@@ -6757,7 +4626,6 @@ final class ManualContentTransitionAnimator {
}
}
-
@MainActor
func waitForWindowShowingSplitView(
_ rootViewController: ReviewMonitorRootViewController,
@@ -6824,15 +4692,15 @@ func waitForSidebarPresentation(
@MainActor
func waitForWorkspaceExpanded(
_ viewController: ReviewMonitorSidebarViewController,
- workspace: CodexReviewWorkspace,
+ workspaceCWD: String,
_ expected: Bool,
timeout: Duration = .seconds(2)
) async throws {
let viewControllerBox = UncheckedSendableBox(viewController)
- let workspaceBox = UncheckedSendableBox(workspace)
+ let workspaceCWDBox = UncheckedSendableBox(workspaceCWD)
try await withTestTimeout(timeout) {
while await MainActor.run(body: {
- viewControllerBox.value.workspaceIsExpandedForTesting(workspaceBox.value) != expected
+ viewControllerBox.value.workspaceIsExpandedForTesting(cwd: workspaceCWDBox.value) != expected
}) {
try Task.checkCancellation()
await Task.yield()
@@ -6988,17 +4856,12 @@ func waitForObservedValueFromCurrentObservation(
@MainActor
func awaitTransportRender(
_ transport: ReviewMonitorTransportViewController,
- observation explicitObservation: PortableObservationTracking.Token? = nil,
+ expectedSelection: ReviewMonitorTransportViewController.DisplayedSelectionForTesting?,
timeout: Duration = .seconds(2),
- matching predicate: (@Sendable (ReviewMonitorTransportViewController.RenderSnapshotForTesting) -> Bool)? = nil
+ matching predicate: @escaping @Sendable (ReviewMonitorTransportViewController.RenderSnapshotForTesting) -> Bool = { _ in true }
) async throws -> ReviewMonitorTransportViewController.RenderSnapshotForTesting {
- _ = try #require(explicitObservation ?? transport.observationForExpectedRenderedStateForTesting)
- let expectedState = transport.expectedRenderedStateForTesting
let resolvedPredicate: @Sendable (ReviewMonitorTransportViewController.RenderedStateForTesting) -> Bool = { state in
- if let predicate {
- return predicate(state.snapshot)
- }
- return state == expectedState
+ state.selection == expectedSelection && predicate(state.snapshot)
}
let clock = ContinuousClock()
@@ -7015,7 +4878,11 @@ func awaitTransportRender(
if transport.logRenderIsIdleForTesting, resolvedPredicate(state) {
return state.snapshot
}
- throw TestFailure("timed out waiting for rendered transport state")
+ throw TestFailure(
+ "timed out waiting for rendered transport state: "
+ + "idle=\(transport.logRenderIsIdleForTesting), "
+ + "actual=\(state), expectedSelection=\(String(describing: expectedSelection))"
+ )
}
@MainActor
@@ -7060,18 +4927,55 @@ func expectLogVisibleFragmentsWithoutForcingLayout(
@MainActor
func awaitContentPaneRender(
_ contentPane: ReviewMonitorTransportViewController,
- observation explicitObservation: PortableObservationTracking.Token? = nil,
+ expectedSelection: ReviewMonitorTransportViewController.DisplayedSelectionForTesting?,
timeout: Duration = .seconds(2),
- matching predicate: (@Sendable (ReviewMonitorTransportViewController.RenderSnapshotForTesting) -> Bool)? = nil
+ matching predicate: @escaping @Sendable (ReviewMonitorTransportViewController.RenderSnapshotForTesting) -> Bool = { _ in true }
) async throws -> ReviewMonitorTransportViewController.RenderSnapshotForTesting {
try await awaitTransportRender(
contentPane,
- observation: explicitObservation,
+ expectedSelection: expectedSelection,
timeout: timeout,
matching: predicate
)
}
+@MainActor
+func waitForOverlayScrollerHideRequest(
+ in transport: ReviewMonitorTransportViewController,
+ exceeding previousCount: Int,
+ timeout: Duration = .seconds(2)
+) async throws {
+ let clock = ContinuousClock()
+ let deadline = clock.now + timeout
+ repeat {
+ if transport.logOverlayScrollerHideRequestCountForTesting > previousCount {
+ return
+ }
+ try Task.checkCancellation()
+ await Task.yield()
+ } while clock.now < deadline
+
+ throw TestFailure("timed out waiting for overlay scroller hide request")
+}
+
+@MainActor
+func waitForLogPinnedToBottom(
+ in transport: ReviewMonitorTransportViewController,
+ timeout: Duration = .seconds(2)
+) async throws {
+ let clock = ContinuousClock()
+ let deadline = clock.now + timeout
+ repeat {
+ if transport.isLogPinnedToBottomForTesting {
+ return
+ }
+ try Task.checkCancellation()
+ await Task.yield()
+ } while clock.now < deadline
+
+ throw TestFailure("timed out waiting for log to pin to bottom")
+}
+
final class UncheckedSendableBox: @unchecked Sendable {
let value: Value
@@ -7081,54 +4985,63 @@ final class UncheckedSendableBox: @unchecked Sendable {
}
@MainActor
-func makeJob(
+func makeReviewChatFixtureForTesting(
id: String = UUID().uuidString,
cwd: String = "/tmp/repo",
startedAt: Date = Date(timeIntervalSince1970: 200),
- status: ReviewJobState,
+ status: ReviewChatFixtureStatus,
targetSummary: String,
summary: String? = nil,
- reviewResult: ParsedReviewResult? = nil,
- logText: String = "",
- rawLogText: String = ""
-) -> CodexReviewJob {
- CodexReviewJob.makeForTesting(
+ logText: String = ""
+) -> ReviewChatFixtureForTesting {
+ let trimmedLogText = logText.trimmingCharacters(in: .newlines)
+ return makeReviewChatFixtureForTesting(
id: id,
cwd: cwd,
- targetSummary: targetSummary,
- threadID: status == .queued ? nil : UUID().uuidString,
- turnID: UUID().uuidString,
+ title: targetSummary,
+ preview: summary ?? status.displayText,
status: status,
startedAt: startedAt,
- endedAt: status.isTerminal ? startedAt.addingTimeInterval(1) : nil,
- summary: summary ?? status.displayText,
- reviewResult: reviewResult,
- lastAgentMessage: "",
- logEntries:
- (logText.isEmpty ? [] : [.init(kind: .agentMessage, text: logText.trimmingCharacters(in: .newlines))])
- + (rawLogText.isEmpty ? [] : rawLogText.split(separator: "\n", omittingEmptySubsequences: false).map {
- .init(kind: .diagnostic, text: String($0))
- }),
- errorMessage: status == .failed ? summary ?? status.displayText : nil
+ updatedAt: status.isTerminal ? startedAt.addingTimeInterval(1) : startedAt,
+ chatEntries: trimmedLogText.isEmpty
+ ? []
+ : [.init(kind: .agentMessage, groupID: "fixture-log-\(id)", text: trimmedLogText)]
)
}
@MainActor
-func makeWorkspaces(from jobs: [CodexReviewJob]) -> [CodexReviewWorkspace] {
- var seenCWDs: Set = []
- var order: [String] = []
- for job in jobs {
- if seenCWDs.contains(job.cwd) == false {
- order.insert(job.cwd, at: 0)
- seenCWDs.insert(job.cwd)
- }
+func reviewChatCellTestChat(
+ id: String,
+ title: String,
+ workspaceCWD: String
+) async throws -> CodexChat {
+ let chatID = CodexThreadID(rawValue: id)
+ let workspaceURL = URL(fileURLWithPath: workspaceCWD, isDirectory: true)
+ try FileManager.default.createDirectory(at: workspaceURL, withIntermediateDirectories: true)
+ try FileManager.default.createDirectory(
+ at: workspaceURL.appendingPathComponent(".git", isDirectory: true),
+ withIntermediateDirectories: true
+ )
+ let runtime = try await CodexAppServerTestRuntime.start()
+ let context = CodexModelContainer(appServer: runtime.server).mainContext
+ try await runtime.transport.enqueueThreadList(
+ .init(
+ threads: [
+ .init(
+ id: chatID,
+ workspace: workspaceURL,
+ name: title,
+ updatedAt: Date(timeIntervalSince1970: 200),
+ status: .idle
+ )
+ ]
+ ))
+ let results = context.fetchedResults(for: CodexFetchDescriptor())
+ try await results.performFetch()
+ guard let chat = results.items.first else {
+ throw TestFailure("Expected test CodexChat for \(id).")
}
- return order.map { CodexReviewWorkspace(cwd: $0) }
-}
-
-@MainActor
-func makeSidebarContent(from jobs: [CodexReviewJob]) -> (workspaces: [CodexReviewWorkspace], jobs: [CodexReviewJob]) {
- (makeWorkspaces(from: jobs), Array(jobs.reversed()))
+ return chat
}
struct LinkedWorktreeFixtureForTesting {
@@ -7146,16 +5059,20 @@ func makeLinkedWorktreeFixtureForTesting(
let repositoryURL = rootURL.appendingPathComponent(repositoryName, isDirectory: true)
let repositoryGitURL = repositoryURL.appendingPathComponent(".git", isDirectory: true)
let worktreesURL = rootURL.appendingPathComponent("worktrees", isDirectory: true)
- let firstWorktreeURL = worktreesURL
+ let firstWorktreeURL =
+ worktreesURL
.appendingPathComponent("825b", isDirectory: true)
.appendingPathComponent(repositoryName, isDirectory: true)
- let secondWorktreeURL = worktreesURL
+ let secondWorktreeURL =
+ worktreesURL
.appendingPathComponent("be78", isDirectory: true)
.appendingPathComponent(repositoryName, isDirectory: true)
- let firstGitDirURL = repositoryGitURL
+ let firstGitDirURL =
+ repositoryGitURL
.appendingPathComponent("worktrees", isDirectory: true)
.appendingPathComponent("825b", isDirectory: true)
- let secondGitDirURL = repositoryGitURL
+ let secondGitDirURL =
+ repositoryGitURL
.appendingPathComponent("worktrees", isDirectory: true)
.appendingPathComponent("be78", isDirectory: true)
@@ -7274,15 +5191,15 @@ func applyTestAuthState(
) {
auth.updatePhase(state.phase)
if let accountEmail = state.accountEmail {
- let account = CodexAccount(
+ let account = CodexReviewAccount(
email: accountEmail,
planType: state.accountPlanType ?? "pro"
)
auth.updatePersistedAccounts([account])
auth.updateAccount(account)
} else {
- auth.updatePersistedAccounts([CodexAccount]())
- auth.updateAccount(nil as CodexAccount?)
+ auth.updatePersistedAccounts([CodexReviewAccount]())
+ auth.updateAccount(nil as CodexReviewAccount?)
}
}
@@ -7302,47 +5219,73 @@ extension CodexReviewStore {
serverState: CodexReviewServerState,
authState: TestAuthState = .signedOut,
serverURL: URL? = nil,
- workspaces: [CodexReviewWorkspace],
- jobs: [CodexReviewJob] = [],
+ fixtures: [ReviewChatFixtureForTesting],
+ settingsSnapshot: CodexReviewSettings.Snapshot? = nil
+ ) {
+ loadForTesting(
+ serverState: serverState,
+ authState: authState,
+ serverURL: serverURL,
+ settingsSnapshot: settingsSnapshot
+ )
+ installPreviewChatLogSourceForTesting(on: self, fixtures: fixtures)
+ }
+
+ func loadForTesting(
+ serverState: CodexReviewServerState,
+ authState: TestAuthState = .signedOut,
+ serverURL: URL? = nil,
settingsSnapshot: CodexReviewSettings.Snapshot? = nil
) {
loadForTesting(
serverState: serverState,
authPhase: authState.phase,
account: authState.accountEmail.map {
- CodexAccount(
+ CodexReviewAccount(
email: $0,
planType: authState.accountPlanType ?? "pro"
)
},
persistedAccounts: authState.accountEmail.map {
[
- CodexAccount(
+ CodexReviewAccount(
email: $0,
planType: authState.accountPlanType ?? "pro"
)
]
} ?? [],
serverURL: serverURL,
- workspaces: workspaces,
- jobs: jobs,
+ reviewRuns: [],
settingsSnapshot: settingsSnapshot
)
}
- func loadForTesting(
+ func loadReviewCancellationStateForTesting(
serverState: CodexReviewServerState,
authState: TestAuthState = .signedOut,
serverURL: URL? = nil,
- content: (workspaces: [CodexReviewWorkspace], jobs: [CodexReviewJob]),
+ reviewRuns: [ReviewRunRecord],
settingsSnapshot: CodexReviewSettings.Snapshot? = nil
) {
loadForTesting(
serverState: serverState,
- authState: authState,
+ authPhase: authState.phase,
+ account: authState.accountEmail.map {
+ CodexReviewAccount(
+ email: $0,
+ planType: authState.accountPlanType ?? "pro"
+ )
+ },
+ persistedAccounts: authState.accountEmail.map {
+ [
+ CodexReviewAccount(
+ email: $0,
+ planType: authState.accountPlanType ?? "pro"
+ )
+ ]
+ } ?? [],
serverURL: serverURL,
- workspaces: content.workspaces,
- jobs: content.jobs,
+ reviewRuns: reviewRuns,
settingsSnapshot: settingsSnapshot
)
}
@@ -7404,7 +5347,7 @@ final class AuthActionBackend: PreviewCodexReviewStoreBackend {
init(initialAuthState: TestAuthState = .signedOut) {
let initialAccount = initialAuthState.accountEmail.map {
- CodexAccount(email: $0, planType: initialAuthState.accountPlanType ?? "pro")
+ CodexReviewAccount(email: $0, planType: initialAuthState.accountPlanType ?? "pro")
}
super.init(
seed: .init(
@@ -7469,7 +5412,9 @@ final class FailingCancellationBackend: PreviewCodexReviewStoreBackend {
override func waitUntilStopped() async {}
- override func interruptReview(_: CodexReviewBackendModel.Review.Run, reason _: CodexReviewBackendModel.CancellationReason) async throws {
+ override func interruptReview(
+ _: CodexReviewBackendModel.Review.Run, reason _: CodexReviewBackendModel.CancellationReason
+ ) async throws {
throw CodexReviewAPI.Error.io("Cancellation failed.")
}
diff --git a/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.pbxproj b/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.pbxproj
index 909c0e50..84624022 100644
--- a/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.pbxproj
+++ b/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.pbxproj
@@ -9,8 +9,9 @@
/* Begin PBXBuildFile section */
AB663A752F7B9B7F0076BA6D /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = AB663A742F7B9B7F0076BA6D /* AppIcon.icon */; };
F0A100000000000000000004 /* ReviewUI in Frameworks */ = {isa = PBXBuildFile; productRef = F0A100000000000000000005 /* ReviewUI */; };
- F0A10000000000000000000A /* CodexReview in Frameworks */ = {isa = PBXBuildFile; productRef = F0A10000000000000000000B /* CodexReview */; };
+ F0A10000000000000000000A /* CodexReviewKit in Frameworks */ = {isa = PBXBuildFile; productRef = F0A10000000000000000000B /* CodexReviewKit */; };
F0A10000000000000000000C /* CodexReviewHost in Frameworks */ = {isa = PBXBuildFile; productRef = F0A10000000000000000000D /* CodexReviewHost */; };
+ F0A10000000000000000000E /* ReviewUIPreviewSupport in Frameworks */ = {isa = PBXBuildFile; productRef = F0A10000000000000000000F /* ReviewUIPreviewSupport */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -61,7 +62,8 @@
buildActionMask = 2147483647;
files = (
F0A100000000000000000004 /* ReviewUI in Frameworks */,
- F0A10000000000000000000A /* CodexReview in Frameworks */,
+ F0A10000000000000000000E /* ReviewUIPreviewSupport in Frameworks */,
+ F0A10000000000000000000A /* CodexReviewKit in Frameworks */,
F0A10000000000000000000C /* CodexReviewHost in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -116,7 +118,8 @@
name = CodexReviewMonitor;
packageProductDependencies = (
F0A100000000000000000005 /* ReviewUI */,
- F0A10000000000000000000B /* CodexReview */,
+ F0A10000000000000000000F /* ReviewUIPreviewSupport */,
+ F0A10000000000000000000B /* CodexReviewKit */,
F0A10000000000000000000D /* CodexReviewHost */,
);
productName = CodexReviewMonitor;
@@ -565,16 +568,21 @@
package = F0A100000000000000000002 /* XCLocalSwiftPackageReference "../.." */;
productName = ReviewUI;
};
- F0A10000000000000000000B /* CodexReview */ = {
+ F0A10000000000000000000B /* CodexReviewKit */ = {
isa = XCSwiftPackageProductDependency;
package = F0A100000000000000000002 /* XCLocalSwiftPackageReference "../.." */;
- productName = CodexReview;
+ productName = CodexReviewKit;
};
F0A10000000000000000000D /* CodexReviewHost */ = {
isa = XCSwiftPackageProductDependency;
package = F0A100000000000000000002 /* XCLocalSwiftPackageReference "../.." */;
productName = CodexReviewHost;
};
+ F0A10000000000000000000F /* ReviewUIPreviewSupport */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = F0A100000000000000000002 /* XCLocalSwiftPackageReference "../.." */;
+ productName = ReviewUIPreviewSupport;
+ };
/* End XCSwiftPackageProductDependency section */
};
rootObject = AB19F4272F77D8AD009726F1 /* Project object */;
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 624ee60e..79e0d609 100644
--- a/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -1,6 +1,14 @@
{
"originHash" : "11d1673255c652381957935c0aa0fcc8f6cba52200a3e3e3c7296d58e6207f76",
"pins" : [
+ {
+ "identity" : "codexkit",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/lynnswap/CodexKit.git",
+ "state" : {
+ "revision" : "6de9e019d35095390252102bd30b069e1090c874"
+ }
+ },
{
"identity" : "eventsource",
"kind" : "remoteSourceControl",
@@ -19,6 +27,15 @@
"version" : "0.12.0"
}
},
+ {
+ "identity" : "swift-async-algorithms",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-async-algorithms",
+ "state" : {
+ "revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272",
+ "version" : "1.1.3"
+ }
+ },
{
"identity" : "swift-atomics",
"kind" : "remoteSourceControl",
diff --git a/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/xcshareddata/xcschemes/CodexReviewMonitor.xcscheme b/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/xcshareddata/xcschemes/CodexReviewMonitor.xcscheme
index e1544852..d6f650f2 100644
--- a/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/xcshareddata/xcschemes/CodexReviewMonitor.xcscheme
+++ b/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/xcshareddata/xcschemes/CodexReviewMonitor.xcscheme
@@ -40,7 +40,7 @@
@@ -81,11 +81,6 @@
-
-
NSWindow?
typealias LiveStoreFactory = (
CodexReviewRuntime.Preferences,
- CodexReviewNativeAuthentication.Configuration?
+ CodexReviewNativeAuthentication.Configuration?,
+ CodexReviewAppServerLifecycleHandler?
) -> CodexReviewStore
- var makeStore: (ReviewMonitorLaunchContext, @escaping PresentationAnchorProvider) -> CodexReviewStore
+ var makeDependencies: (
+ ReviewMonitorLaunchContext,
+ @escaping PresentationAnchorProvider
+ ) -> ReviewMonitorAppDependencies
var makeLifecycleController: (
any ReviewMonitorLifecycleStore,
ReviewMonitorLaunchContext
) -> ReviewMonitorLifecycleController
- var makeWindowController: (CodexReviewStore, @escaping @MainActor () -> Void) -> NSWindowController
+ var makeWindowController: (
+ ReviewMonitorAppDependencies,
+ @escaping @MainActor () -> Void
+ ) -> NSWindowController
var makeSettingsWindowController: () -> NSWindowController
init(
- makeStore: @escaping (
+ makeDependencies: @escaping (
ReviewMonitorLaunchContext,
@escaping PresentationAnchorProvider
- ) -> CodexReviewStore,
+ ) -> ReviewMonitorAppDependencies,
makeLifecycleController: @escaping (
any ReviewMonitorLifecycleStore,
ReviewMonitorLaunchContext
@@ -245,7 +267,7 @@ struct ReviewMonitorAppComposition {
)
},
makeWindowController: @escaping (
- CodexReviewStore,
+ ReviewMonitorAppDependencies,
@escaping @MainActor () -> Void
) -> NSWindowController,
makeSettingsWindowController: @escaping () -> NSWindowController = {
@@ -254,7 +276,7 @@ struct ReviewMonitorAppComposition {
)
}
) {
- self.makeStore = makeStore
+ self.makeDependencies = makeDependencies
self.makeLifecycleController = makeLifecycleController
self.makeWindowController = makeWindowController
self.makeSettingsWindowController = makeSettingsWindowController
@@ -262,30 +284,50 @@ struct ReviewMonitorAppComposition {
static func live(
runtimePreferencesStore: any CodexReviewRuntime.PreferencesStore = CodexReviewRuntime.UserDefaultsPreferencesStore(),
- makeLiveStore: @escaping LiveStoreFactory = { runtimePreferences, nativeAuthenticationConfiguration in
+ makeLiveStore: @escaping LiveStoreFactory = { runtimePreferences, nativeAuthenticationConfiguration, appServerLifecycleHandler in
CodexReviewStore.makeLiveStore(
runtimePreferences: runtimePreferences,
- nativeAuthenticationConfiguration: nativeAuthenticationConfiguration
+ nativeAuthenticationConfiguration: nativeAuthenticationConfiguration,
+ appServerLifecycleHandler: appServerLifecycleHandler
)
}
) -> ReviewMonitorAppComposition {
- ReviewMonitorAppComposition(
- makeStore: { context, presentationAnchorProvider in
+ let codexModelSource = ReviewMonitorCodexModelSource()
+ return ReviewMonitorAppComposition(
+ makeDependencies: { context, presentationAnchorProvider in
if context.requestsPreviewContent {
- return ReviewMonitorPreviewContent.makeStore()
+ let previewContent = ReviewMonitorPreviewContent.makeContentSource()
+ return ReviewMonitorAppDependencies(
+ store: previewContent.store,
+ previewContent: previewContent
+ )
}
- return makeLiveStore(
+ let store = makeLiveStore(
runtimePreferencesStore.load(),
- .init(
+ CodexReviewNativeAuthentication.Configuration(
callbackScheme: ReviewMonitorNativeAuthentication.callbackScheme,
browserSessionPolicy: .ephemeral,
presentationAnchorProvider: presentationAnchorProvider
)
- )
+ ) { modelContainer in
+ if let modelContainer {
+ codexModelSource.install(container: modelContainer)
+ } else {
+ codexModelSource.clear()
+ }
+ }
+ return ReviewMonitorAppDependencies(store: store)
},
- makeWindowController: { store, showSettings in
- ReviewMonitorWindowController(
- store: store,
+ makeWindowController: { dependencies, showSettings in
+ if let previewContent = dependencies.previewContent {
+ return ReviewMonitorWindowController(
+ previewContent: previewContent,
+ showSettings: showSettings
+ )
+ }
+ return ReviewMonitorWindowController(
+ appStore: dependencies.store,
+ codexModelSource: codexModelSource,
showSettings: showSettings
)
},
@@ -309,14 +351,15 @@ final class ReviewMonitorAppDelegate: NSObject, NSApplicationDelegate {
private var launchMode: ReviewMonitorLaunchMode {
launchContext.launchMode
}
- lazy var store: CodexReviewStore = {
- composition.makeStore(launchContext) { [weak presentationAnchorSource] in
+ lazy var appDependencies: ReviewMonitorAppDependencies = {
+ composition.makeDependencies(launchContext) { [weak presentationAnchorSource] in
presentationAnchorSource?.window
}
}()
+ lazy var store: CodexReviewStore = appDependencies.store
lazy var lifecycle = composition.makeLifecycleController(store, launchContext)
lazy var windowController: NSWindowController = {
- let windowController = composition.makeWindowController(store) { [weak self] in
+ let windowController = composition.makeWindowController(appDependencies) { [weak self] in
self?.showSettingsWindow(nil)
}
presentationAnchorSource.window = windowController.window
diff --git a/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift b/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift
index ffe13cee..a6aadbe3 100644
--- a/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift
+++ b/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift
@@ -1,7 +1,9 @@
import AppKit
import Foundation
-import CodexReview
+import CodexReviewKit
import CodexReviewHost
+@testable import ReviewUI
+import ReviewUIPreviewSupport
import Testing
@testable import CodexReviewMonitor
@@ -15,7 +17,7 @@ struct CodexReviewMonitorCITests {
arguments: []
)
- #expect(environment[ReviewMonitorLaunchEnvironment.mockJobsKey] == "1")
+ #expect(environment[ReviewMonitorLaunchEnvironment.reviewModeKey] == "1")
#expect(context.requestsPreviewContent)
#expect(context.shouldStartEmbeddedServer == false)
}
@@ -86,12 +88,13 @@ struct CodexReviewMonitorCITests {
let recorder = WindowControllerFactoryRecorder()
let settingsWindowController = CountingWindowController()
let composition = ReviewMonitorAppComposition(
- makeStore: { context, _ in
+ makeDependencies: { context, _ in
capturedContext = context
- return expectedStore
+ return ReviewMonitorAppDependencies(store: expectedStore)
},
- makeWindowController: { store, showSettings in
- #expect(store === expectedStore)
+ makeWindowController: { dependencies, showSettings in
+ #expect(dependencies.store === expectedStore)
+ #expect(dependencies.previewContent == nil)
capturedShowSettings = showSettings
return recorder.makeWindowController()
},
@@ -103,7 +106,7 @@ struct CodexReviewMonitorCITests {
launchContextProvider: {
ReviewMonitorLaunchContext(
environment: [
- ReviewMonitorLaunchEnvironment.mockJobsKey: "1",
+ ReviewMonitorLaunchEnvironment.reviewModeKey: "1",
],
arguments: [],
launchMode: .application
@@ -143,8 +146,8 @@ struct CodexReviewMonitorCITests {
}
let composition = ReviewMonitorAppComposition(
- makeStore: { _, _ in
- CodexReviewStore.makePreviewStore()
+ makeDependencies: { _, _ in
+ ReviewMonitorAppDependencies(store: CodexReviewStore.makePreviewStore())
},
makeWindowController: { _, _ in
CountingWindowController()
@@ -190,8 +193,8 @@ struct CodexReviewMonitorCITests {
@Test func appDelegateShowsInjectedSettingsWindowController() {
let settingsWindowController = CountingWindowController()
let composition = ReviewMonitorAppComposition(
- makeStore: { _, _ in
- CodexReviewStore.makePreviewStore()
+ makeDependencies: { _, _ in
+ ReviewMonitorAppDependencies(store: CodexReviewStore.makePreviewStore())
},
makeWindowController: { _, _ in
CountingWindowController()
@@ -222,7 +225,7 @@ struct CodexReviewMonitorCITests {
var didCallLiveStoreFactory = false
let composition = ReviewMonitorAppComposition.live(
runtimePreferencesStore: runtimePreferencesStore,
- makeLiveStore: { _, _ in
+ makeLiveStore: { _, _, _ in
didCallLiveStoreFactory = true
Issue.record("Preview store creation should not build a live store.")
return CodexReviewStore.makePreviewStore()
@@ -230,24 +233,85 @@ struct CodexReviewMonitorCITests {
)
let context = ReviewMonitorLaunchContext(
environment: [
- ReviewMonitorLaunchEnvironment.mockJobsKey: "1",
+ ReviewMonitorLaunchEnvironment.reviewModeKey: "1",
],
arguments: [],
launchMode: .application
)
var didRequestPresentationAnchor = false
- _ = composition.makeStore(context) {
+ let dependencies = composition.makeDependencies(context) {
didRequestPresentationAnchor = true
Issue.record("Preview store creation should not request a presentation anchor.")
return nil
}
+ #expect(dependencies.previewContent != nil)
+ #expect(dependencies.previewContent?.store === dependencies.store)
#expect(didRequestPresentationAnchor == false)
#expect(didCallLiveStoreFactory == false)
#expect(context.shouldStartEmbeddedServer == false)
}
+ @Test func liveCompositionPreviewWindowRendersPreviewChatLog() async throws {
+ let runtimePreferencesStore = FailingRuntimePreferencesStore()
+ var didCallLiveStoreFactory = false
+ let composition = ReviewMonitorAppComposition.live(
+ runtimePreferencesStore: runtimePreferencesStore,
+ makeLiveStore: { _, _, _ in
+ didCallLiveStoreFactory = true
+ Issue.record("Preview window creation should not build a live store.")
+ return CodexReviewStore.makePreviewStore()
+ }
+ )
+ let context = ReviewMonitorLaunchContext(
+ environment: [
+ ReviewMonitorLaunchEnvironment.reviewModeKey: "1",
+ ],
+ arguments: [],
+ launchMode: .application
+ )
+ let dependencies = composition.makeDependencies(context) {
+ Issue.record("Preview store creation should not request a presentation anchor.")
+ return nil
+ }
+
+ let windowController = composition.makeWindowController(dependencies) {}
+ let rootViewController = try #require(
+ windowController.window?.contentViewController as? ReviewMonitorRootViewController
+ )
+ rootViewController.prepareForSwiftUIPreviewRendering()
+
+ let sidebar = rootViewController.splitViewControllerForTesting.sidebarViewControllerForTesting
+ try await waitForPreviewCondition {
+ sidebar.sidebarKindForTesting == .chatList
+ && sidebar.displayedCodexSidebarTitlesForTesting.contains("workspace-alpha")
+ && sidebar.displayedCodexSidebarTitlesForTesting.contains("Branch: feature/workspace-alpha-sidebar")
+ }
+ #expect(sidebar.isShowingEmptyStateForTesting == false)
+
+ let transport = rootViewController.splitViewControllerForTesting.transportViewControllerForTesting
+ let initialSnapshot = try await awaitPreviewTransportRender(transport) { snapshot in
+ snapshot.log.isEmpty == false && snapshot.isShowingEmptyState == false
+ }
+
+ #expect(didCallLiveStoreFactory == false)
+ if case .chat? = transport.renderedStateForTesting.selection {
+ } else {
+ Issue.record("Expected preview window to select a chat.")
+ }
+ #expect(initialSnapshot.log.isEmpty == false)
+
+ let nextTick = try #require(await rootViewController.appendPreviewChatLogStreamTickForTesting())
+ #expect(nextTick == 1)
+ let updatedSnapshot = try await awaitPreviewTransportRender(transport) { snapshot in
+ snapshot.log.count > initialSnapshot.log.count
+ && snapshot.log.contains("Turn started")
+ && snapshot.isShowingEmptyState == false
+ }
+ #expect(updatedSnapshot.log.contains("Turn started"))
+ }
+
@Test func liveCompositionPassesLoadedRuntimePreferencesToApplicationStoreFactory() {
let expectedRuntimePreferences = CodexReviewRuntime.Preferences(
codexHomePath: FileManager.default.temporaryDirectory
@@ -266,7 +330,7 @@ struct CodexReviewMonitorCITests {
var capturedAuthenticationConfiguration: CodexReviewNativeAuthentication.Configuration?
let composition = ReviewMonitorAppComposition.live(
runtimePreferencesStore: runtimePreferencesStore,
- makeLiveStore: { runtimePreferences, authenticationConfiguration in
+ makeLiveStore: { runtimePreferences, authenticationConfiguration, _ in
capturedRuntimePreferences = runtimePreferences
capturedAuthenticationConfiguration = authenticationConfiguration
return expectedStore
@@ -279,12 +343,14 @@ struct CodexReviewMonitorCITests {
)
var didRequestPresentationAnchor = false
- let store = composition.makeStore(context) {
+ let dependencies = composition.makeDependencies(context) {
didRequestPresentationAnchor = true
return nil
}
+ let store = dependencies.store
#expect(store === expectedStore)
+ #expect(dependencies.previewContent == nil)
#expect(capturedRuntimePreferences == expectedRuntimePreferences)
#expect(didRequestPresentationAnchor == false)
#expect(capturedAuthenticationConfiguration?.callbackScheme == "lynnpd.CodexReviewMonitor.auth")
@@ -296,6 +362,31 @@ struct CodexReviewMonitorCITests {
#expect(didRequestPresentationAnchor)
}
+ @Test func liveCompositionPassesAppServerLifecycleHandlerToLiveStoreFactory() {
+ let runtimePreferencesStore = RuntimePreferencesStoreStub()
+ let expectedStore = CodexReviewStore.makePreviewStore()
+ var capturedLifecycleHandler: CodexReviewAppServerLifecycleHandler?
+ let composition = ReviewMonitorAppComposition.live(
+ runtimePreferencesStore: runtimePreferencesStore,
+ makeLiveStore: { _, _, appServerLifecycleHandler in
+ capturedLifecycleHandler = appServerLifecycleHandler
+ return expectedStore
+ }
+ )
+ let context = ReviewMonitorLaunchContext(
+ environment: [:],
+ arguments: [],
+ launchMode: .application
+ )
+
+ let dependencies = composition.makeDependencies(context) { nil }
+ let store = dependencies.store
+
+ #expect(store === expectedStore)
+ #expect(dependencies.previewContent == nil)
+ #expect(capturedLifecycleHandler != nil)
+ }
+
@Test func liveCompositionBuildsLifecycleFromLaunchContext() async {
let store = FakeLifecycleStore()
let lifecycle = ReviewMonitorAppComposition.live().makeLifecycleController(
@@ -527,8 +618,8 @@ struct CodexReviewMonitorCITests {
}
let composition = ReviewMonitorAppComposition(
- makeStore: { _, _ in
- CodexReviewStore.makePreviewStore()
+ makeDependencies: { _, _ in
+ ReviewMonitorAppDependencies(store: CodexReviewStore.makePreviewStore())
},
makeWindowController: { _, _ in
CountingWindowController()
@@ -728,6 +819,40 @@ private actor TestValueQueue {
}
}
+@MainActor
+private func awaitPreviewTransportRender(
+ _ transport: ReviewMonitorTransportViewController,
+ matching predicate: (ReviewMonitorTransportViewController.RenderSnapshotForTesting) -> Bool
+) async throws -> ReviewMonitorTransportViewController.RenderSnapshotForTesting {
+ for _ in 0..<100 {
+ let state = transport.renderedStateForTesting
+ if transport.logRenderIsIdleForTesting,
+ predicate(state.snapshot)
+ {
+ return state.snapshot
+ }
+ try await Task.sleep(for: .milliseconds(20))
+ }
+ let state = transport.renderedStateForTesting
+ Issue.record(
+ "Timed out waiting for preview transport render: selection=\(String(describing: state.selection)), log=\(state.snapshot.log)"
+ )
+ return state.snapshot
+}
+
+@MainActor
+private func waitForPreviewCondition(
+ _ condition: @escaping @MainActor () -> Bool
+) async throws {
+ for _ in 0..<100 {
+ if condition() {
+ return
+ }
+ try await Task.sleep(for: .milliseconds(20))
+ }
+ Issue.record("Timed out waiting for preview condition.")
+}
+
@MainActor
private final class WindowControllerFactoryRecorder {
private(set) var makeCallCount = 0